diff --git a/instrumentation/opentelemetry/internal/metrics/linux_metrics.go b/instrumentation/opentelemetry/internal/metrics/linux_metrics.go new file mode 100644 index 0000000..fc16166 --- /dev/null +++ b/instrumentation/opentelemetry/internal/metrics/linux_metrics.go @@ -0,0 +1,95 @@ +//go:build linux + +package metrics + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/tklauser/go-sysconf" +) + +const procStatArrayLength = 52 + +var ( + clkTck = getClockTicks() + pageSize = float64(os.Getpagesize()) +) + +type processStats struct { + utime float64 + stime float64 + cutime float64 + cstime float64 + rss float64 +} + +type linuxMetrics struct { + memory float64 + cpuSecondsTotal float64 +} + +func newSystemMetrics() (systemMetrics, error) { + lm := &linuxMetrics{} + stats, err := lm.processStatsFromPid(os.Getpid()) + if err != nil { + return nil, err + } + lm.memory = stats.rss * pageSize + lm.cpuSecondsTotal = (stats.stime + stats.utime + stats.cstime + stats.cutime) / clkTck + return lm, nil +} + +func (lm *linuxMetrics) getMemory() float64 { + return lm.memory +} + +func (lm *linuxMetrics) getCPU() float64 { + return lm.cpuSecondsTotal +} + +func (lm *linuxMetrics) processStatsFromPid(pid int) (*processStats, error) { + procFilepath := filepath.Join("/proc", strconv.Itoa(pid), "stat") + procStatFileBytes, err := os.ReadFile(filepath.Clean(procFilepath)) + if err != nil { + return nil, err + } + stat, err := lm.parseProcStatFile(procStatFileBytes, procFilepath) + if err != nil { + return nil, err + } + return stat, nil +} + +// ref: /proc/pid/stat section of https://man7.org/linux/man-pages/man5/proc.5.html +func (lm *linuxMetrics) parseProcStatFile(bytesArr []byte, procFilepath string) (*processStats, error) { + infos := strings.Split(string(bytesArr), " ") + if len(infos) != procStatArrayLength { + return nil, fmt.Errorf("%s file could not be parsed", procFilepath) + } + return &processStats{ + utime: parseFloat(infos[13]), + stime: parseFloat(infos[14]), + cutime: parseFloat(infos[15]), + cstime: parseFloat(infos[16]), + rss: parseFloat(infos[23]), + }, nil +} + +func parseFloat(val string) float64 { + floatVal, _ := strconv.ParseFloat(val, 64) + return floatVal +} + +// sysconf for go. claims to work without cgo or external binaries +// https://pkg.go.dev/github.com/tklauser/go-sysconf@v0.3.14#section-readme +func getClockTicks() float64 { + clktck, err := sysconf.Sysconf(sysconf.SC_CLK_TCK) + if err != nil { + return float64(100) + } + return float64(clktck) +} diff --git a/instrumentation/opentelemetry/internal/metrics/linux_metrics_test.go b/instrumentation/opentelemetry/internal/metrics/linux_metrics_test.go new file mode 100644 index 0000000..a8600db --- /dev/null +++ b/instrumentation/opentelemetry/internal/metrics/linux_metrics_test.go @@ -0,0 +1,28 @@ +//go:build linux + +package metrics + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +const ( + // Mock data simulating /proc/[pid]/stat content + mockData = "85 (md) I 2 0 0 0 -1 698880 0 0 0 0 100 200 300 400 0 -20 1 0 223 0 550 18437615 0 0 0 0 0 0 0 2647 0 1 0 0 17 1 0 0 0 0 0 0 0 0 0 0 0 0 0" +) + +func TestParseProcStatFile(t *testing.T) { + lm := &linuxMetrics{} + procFilepath := "/proc/123/stat" + + stat, err := lm.parseProcStatFile([]byte(mockData), procFilepath) + assert.NoError(t, err, "unexpected error while parsing proc stat file") + + assert.Equal(t, 100.0, stat.utime, "utime does not match expected value") + assert.Equal(t, 200.0, stat.stime, "stime does not match expected value") + assert.Equal(t, 300.0, stat.cutime, "cutime does not match expected value") + assert.Equal(t, 400.0, stat.cstime, "cstime does not match expected value") + assert.Equal(t, 550.0, stat.rss, "rss does not match expected value") +} diff --git a/instrumentation/opentelemetry/internal/metrics/metrics.go b/instrumentation/opentelemetry/internal/metrics/metrics.go new file mode 100644 index 0000000..7855a7b --- /dev/null +++ b/instrumentation/opentelemetry/internal/metrics/metrics.go @@ -0,0 +1,58 @@ +package metrics + +import ( + "context" + "fmt" + "log" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/metric" +) + +const meterName = "goagent.hypertrace.org/metrics" + +type systemMetrics interface { + getMemory() float64 + getCPU() float64 +} + +func InitializeSystemMetrics() { + meterProvider := otel.GetMeterProvider() + meter := meterProvider.Meter(meterName) + err := setUpMetricRecorder(meter) + if err != nil { + log.Printf("error initializing metrics, failed to setup metric recorder: %v\n", err) + } +} + +func setUpMetricRecorder(meter metric.Meter) error { + if meter == nil { + return fmt.Errorf("error while setting up metric recorder: meter is nil") + } + cpuSeconds, err := meter.Float64ObservableCounter("hypertrace.agent.cpu.seconds.total", metric.WithDescription("Metric to monitor total CPU seconds")) + if err != nil { + return fmt.Errorf("error while setting up cpu seconds metric counter: %v", err) + } + memory, err := meter.Float64ObservableGauge("hypertrace.agent.memory", metric.WithDescription("Metric to monitor memory usage")) + if err != nil { + return fmt.Errorf("error while setting up memory metric counter: %v", err) + } + // Register the callback function for both cpu_seconds and memory observable gauges + _, err = meter.RegisterCallback( + func(ctx context.Context, result metric.Observer) error { + sysMetrics, err := newSystemMetrics() + if err != nil { + return err + } + result.ObserveFloat64(cpuSeconds, sysMetrics.getCPU()) + result.ObserveFloat64(memory, sysMetrics.getMemory()) + return nil + }, + cpuSeconds, memory, + ) + if err != nil { + log.Fatalf("failed to register callback: %v", err) + return err + } + return nil +} diff --git a/instrumentation/opentelemetry/internal/metrics/noop_metrics.go b/instrumentation/opentelemetry/internal/metrics/noop_metrics.go new file mode 100644 index 0000000..0688b52 --- /dev/null +++ b/instrumentation/opentelemetry/internal/metrics/noop_metrics.go @@ -0,0 +1,17 @@ +//go:build !linux + +package metrics + +type noopMetrics struct{} + +func newSystemMetrics() (systemMetrics, error) { + return &noopMetrics{}, nil +} + +func (nm *noopMetrics) getMemory() float64 { + return 0 +} + +func (nm *noopMetrics) getCPU() float64 { + return 0 +} diff --git a/instrumentation/opentelemetry/internal/metrics/system_metrics.go b/instrumentation/opentelemetry/internal/metrics/system_metrics.go deleted file mode 100644 index 6557f56..0000000 --- a/instrumentation/opentelemetry/internal/metrics/system_metrics.go +++ /dev/null @@ -1,120 +0,0 @@ -package metrics - -import ( - "context" - "fmt" - "log" - "os" - "path/filepath" - "strconv" - "strings" - - "github.com/tklauser/go-sysconf" - "go.opentelemetry.io/otel" - "go.opentelemetry.io/otel/metric" -) - -const meterName = "goagent.hypertrace.org/metrics" - -type systemMetrics struct { - memory float64 - cpuSecondsTotal float64 -} - -type processStats struct { - utime float64 - stime float64 - cutime float64 - cstime float64 - rss float64 -} - -const procStatArrayLength = 52 - -var ( - clkTck = getClockTicks() - pageSize = float64(os.Getpagesize()) -) - -func InitializeSystemMetrics() { - meterProvider := otel.GetMeterProvider() - meter := meterProvider.Meter(meterName) - err := setUpMetricRecorder(meter) - if err != nil { - log.Printf("error initialising metrics, failed to setup metric recorder: %v\n", err) - } -} - -func processStatsFromPid(pid int) (*systemMetrics, error) { - sysInfo := &systemMetrics{} - procFilepath := filepath.Join("/proc", strconv.Itoa(pid), "stat") - var err error - if procStatFileBytes, err := os.ReadFile(filepath.Clean(procFilepath)); err == nil { - if stat, err := parseProcStatFile(procStatFileBytes, procFilepath); err == nil { - sysInfo.memory = stat.rss * pageSize - sysInfo.cpuSecondsTotal = (stat.stime + stat.utime + stat.cstime + stat.cutime) / clkTck - return sysInfo, nil - } - return nil, err - } - return nil, err -} - -// ref: /proc/pid/stat section of https://man7.org/linux/man-pages/man5/proc.5.html -func parseProcStatFile(bytesArr []byte, procFilepath string) (*processStats, error) { - infos := strings.Split(string(bytesArr), " ") - if len(infos) != procStatArrayLength { - return nil, fmt.Errorf("%s file could not be parsed", procFilepath) - } - return &processStats{ - utime: parseFloat(infos[13]), - stime: parseFloat(infos[14]), - cutime: parseFloat(infos[15]), - cstime: parseFloat(infos[16]), - rss: parseFloat(infos[23]), - }, nil -} - -func parseFloat(val string) float64 { - floatVal, _ := strconv.ParseFloat(val, 64) - return floatVal -} - -// sysconf for go. claims to work without cgo or external binaries -// https://pkg.go.dev/github.com/tklauser/go-sysconf@v0.3.14#section-readme -func getClockTicks() float64 { - clktck, err := sysconf.Sysconf(sysconf.SC_CLK_TCK) - if err != nil { - return float64(100) - } - return float64(clktck) -} - -func setUpMetricRecorder(meter metric.Meter) error { - if meter == nil { - return fmt.Errorf("error while setting up metric recorder: meter is nil") - } - cpuSeconds, err := meter.Float64ObservableCounter("hypertrace.agent.cpu.seconds.total", metric.WithDescription("Metric to monitor total CPU seconds")) - if err != nil { - return fmt.Errorf("error while setting up cpu seconds metric counter: %v", err) - } - memory, err := meter.Float64ObservableGauge("hypertrace.agent.memory", metric.WithDescription("Metric to monitor memory usage")) - if err != nil { - return fmt.Errorf("error while setting up memory metric counter: %v", err) - } - // Register the callback function for both cpu_seconds and memory observable gauges - _, err = meter.RegisterCallback( - func(ctx context.Context, result metric.Observer) error { - systemMetrics, err := processStatsFromPid(os.Getpid()) - result.ObserveFloat64(cpuSeconds, systemMetrics.cpuSecondsTotal) - result.ObserveFloat64(memory, systemMetrics.memory) - return err - }, - cpuSeconds, memory, - ) - if err != nil { - log.Fatalf("failed to register callback: %v", err) - return err - } - return nil -}