Skip to content

Commit a15feba

Browse files
authored
Add some process metrics for GOOS=darwin (#107)
Adds support for process metrics on MacOS\Darwin. I added only those that do not require CGO. - ~~process_start_time_seconds~~ - process_cpu_seconds_total - process_virtual_memory_max_bytes - process_virtual_memory_bytes - process_resident_memory_bytes - process_open_fds - process_max_fds Fixes #75 Inspired by https://github.com/prometheus/client_golang/blob/main/prometheus/process_collector_darwin.go
1 parent 58ce999 commit a15feba

File tree

7 files changed

+348
-15
lines changed

7 files changed

+348
-15
lines changed

.github/workflows/build.yml

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
name: build
2+
3+
on:
4+
- push
5+
- pull_request
6+
7+
permissions:
8+
contents: read
9+
10+
concurrency:
11+
cancel-in-progress: true
12+
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
13+
14+
jobs:
15+
build:
16+
name: ${{ matrix.os }}-${{ matrix.arch }}
17+
runs-on: ubuntu-latest
18+
strategy:
19+
fail-fast: false
20+
matrix:
21+
include:
22+
- os: linux
23+
arch: 386
24+
- os: linux
25+
arch: amd64
26+
- os: linux
27+
arch: arm64
28+
- os: linux
29+
arch: arm
30+
- os: linux
31+
arch: ppc64le
32+
- os: linux
33+
arch: s390x
34+
- os: darwin
35+
arch: amd64
36+
- os: darwin
37+
arch: arm64
38+
- os: freebsd
39+
arch: amd64
40+
- os: openbsd
41+
arch: amd64
42+
- os: windows
43+
arch: amd64
44+
steps:
45+
- name: Code checkout
46+
uses: actions/checkout@v6
47+
48+
- name: Setup Go
49+
id: go
50+
uses: actions/setup-go@v6
51+
with:
52+
cache-dependency-path: |
53+
go.sum
54+
go-version-file: 'go.mod'
55+
- run: go version
56+
57+
- name: Build ${{ matrix.os }}-${{ matrix.arch }}
58+
run: GOOS=${{ matrix.os }} GOARCH=${{ matrix.arch }} go build
Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,40 @@
1-
name: main
1+
name: test
2+
23
on:
34
- push
45
- pull_request
6+
7+
permissions:
8+
contents: read
9+
10+
concurrency:
11+
cancel-in-progress: true
12+
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
13+
514
jobs:
6-
build:
7-
name: Build
15+
test:
16+
name: test
817
runs-on: ubuntu-latest
918
steps:
19+
- name: Code checkout
20+
uses: actions/checkout@v6
21+
1022
- name: Setup Go
23+
id: go
1124
uses: actions/setup-go@v6
1225
with:
13-
go-version: oldstable
14-
id: go
15-
- name: Code checkout
16-
uses: actions/checkout@v6
26+
cache-dependency-path: |
27+
go.sum
28+
go-version-file: 'go.mod'
29+
30+
- run: go version
31+
1732
- name: Test
1833
run: |
1934
go test -v ./... -coverprofile=coverage.txt -covermode=atomic
2035
GOARCH=386 go test ./... -coverprofile=coverage.txt -covermode=atomic
2136
go test -v ./... -race
22-
- name: Build
23-
run: |
24-
GOOS=linux go build
25-
GOOS=darwin go build
26-
GOOS=freebsd go build
27-
GOOS=windows go build
28-
GOARCH=386 go build
37+
2938
- name: Publish coverage
3039
uses: codecov/codecov-action@v5
3140
with:

process_metrics_darwin.go

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
//go:build darwin && !ios
2+
3+
package metrics
4+
5+
import (
6+
"encoding/binary"
7+
"errors"
8+
"io"
9+
"log"
10+
"os"
11+
"syscall"
12+
"time"
13+
"unsafe"
14+
)
15+
16+
// errNotImplemented is returned by stub functions that replace cgo functions, when cgo
17+
// isn't available.
18+
var errNotImplemented = errors.New("not implemented")
19+
20+
func writeProcessMetrics(w io.Writer) {
21+
if memInfo, err := getMemory(); err == nil {
22+
WriteGaugeUint64(w, "process_resident_memory_bytes", memInfo.rss)
23+
WriteGaugeUint64(w, "process_virtual_memory_bytes", memInfo.vsize)
24+
} else if !errors.Is(err, errNotImplemented) {
25+
log.Printf("ERROR: metrics: %s", err)
26+
}
27+
28+
// The proc structure returned by kern.proc.pid above has an Rusage member,
29+
// but it is not filled in, so it needs to be fetched by getrusage(2). For
30+
// that call, the UTime, STime, and Maxrss members are filled out, but not
31+
// Ixrss, Idrss, or Isrss for the memory usage. Memory stats will require
32+
// access to the C API to call task_info(TASK_BASIC_INFO).
33+
rusage := syscall.Rusage{}
34+
35+
if err := syscall.Getrusage(syscall.RUSAGE_SELF, &rusage); err == nil {
36+
cpuTime := time.Duration(rusage.Stime.Nano() + rusage.Utime.Nano()).Seconds()
37+
WriteGaugeFloat64(w, "process_cpu_seconds_total", cpuTime)
38+
} else {
39+
log.Printf("ERROR: metrics: %s", err)
40+
}
41+
42+
if addressSpace, err := getSoftLimit(syscall.RLIMIT_AS); err == nil {
43+
WriteGaugeFloat64(w, "process_virtual_memory_max_bytes", float64(addressSpace))
44+
} else {
45+
log.Printf("ERROR: metrics: %s", err)
46+
}
47+
}
48+
49+
func writeFDMetrics(w io.Writer) {
50+
if fds, err := getOpenFileCount(); err == nil {
51+
WriteGaugeFloat64(w, "process_open_fds", fds)
52+
} else {
53+
log.Printf("ERROR: metrics: %s", err)
54+
}
55+
56+
if openFiles, err := getSoftLimit(syscall.RLIMIT_NOFILE); err == nil {
57+
WriteGaugeFloat64(w, "process_max_fds", float64(openFiles))
58+
} else {
59+
log.Printf("ERROR: metrics: %s", err)
60+
}
61+
}
62+
63+
func getOpenFileCount() (float64, error) {
64+
// Alternately, the undocumented proc_pidinfo(PROC_PIDLISTFDS) can be used to
65+
// return a list of open fds, but that requires a way to call C APIs. The
66+
// benefits, however, include fewer system calls and not failing when at the
67+
// open file soft limit.
68+
69+
if dir, err := os.Open("/dev/fd"); err != nil {
70+
return 0.0, err
71+
} else {
72+
defer dir.Close()
73+
74+
// Avoid ReadDir(), as it calls stat(2) on each descriptor. Not only is
75+
// that info not used, but KQUEUE descriptors fail stat(2), which causes
76+
// the whole method to fail.
77+
if names, err := dir.Readdirnames(0); err != nil {
78+
return 0.0, err
79+
} else {
80+
// Subtract 1 to ignore the open /dev/fd descriptor above.
81+
return float64(len(names) - 1), nil
82+
}
83+
}
84+
}
85+
86+
func getSoftLimit(which int) (uint64, error) {
87+
rlimit := syscall.Rlimit{}
88+
89+
if err := syscall.Getrlimit(which, &rlimit); err != nil {
90+
return 0, err
91+
}
92+
93+
return rlimit.Cur, nil
94+
}
95+
96+
func getProcessStartTime() (float64, error) {
97+
// Call sysctl to get kinfo_proc for current process
98+
mib := []int32{1 /* CTL_KERN */, 14 /* KERN_PROC */, 1 /* KERN_PROC_PID */, int32(os.Getpid())}
99+
100+
// First call to get the size
101+
n := uintptr(0)
102+
_, _, errno := syscall.Syscall6(
103+
syscall.SYS___SYSCTL,
104+
uintptr(unsafe.Pointer(&mib[0])),
105+
uintptr(len(mib)),
106+
0,
107+
uintptr(unsafe.Pointer(&n)),
108+
0,
109+
0,
110+
)
111+
if errno != 0 {
112+
return 0, errno
113+
}
114+
if n == 0 {
115+
return 0, syscall.EINVAL
116+
}
117+
118+
// Second call to get the actual data
119+
buf := make([]byte, n)
120+
_, _, errno = syscall.Syscall6(
121+
syscall.SYS___SYSCTL,
122+
uintptr(unsafe.Pointer(&mib[0])),
123+
uintptr(len(mib)),
124+
uintptr(unsafe.Pointer(&buf[0])),
125+
uintptr(unsafe.Pointer(&n)),
126+
0,
127+
0,
128+
)
129+
if errno != 0 {
130+
return 0, errno
131+
}
132+
133+
// The kinfo_proc struct layout on Darwin has p_starttime (struct timeval) at specific offset
134+
// For amd64 and arm64, the offset is at 0x60 (96 bytes)
135+
// struct timeval has tv_sec (int64) and tv_usec (int32)
136+
const startTimeOffset = 0x60
137+
138+
if len(buf) < startTimeOffset+16 {
139+
return 0, syscall.EINVAL
140+
}
141+
142+
// Read tv_sec (8 bytes) and tv_usec (4 bytes)
143+
tvSec := int64(binary.LittleEndian.Uint64(buf[startTimeOffset:]))
144+
tvUsec := int32(binary.LittleEndian.Uint32(buf[startTimeOffset+8:]))
145+
146+
startTime := float64(tvSec) + float64(tvUsec)/1e6
147+
return startTime, nil
148+
}
149+
150+
type memoryInfo struct {
151+
vsize uint64 // Virtual memory size in bytes
152+
rss uint64 // Resident memory size in bytes
153+
}

process_metrics_darwin_cgo.c

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// Copyright 2024 The Prometheus Authors
2+
// Licensed under the Apache License, Version 2.0 (the "License");
3+
// you may not use this file except in compliance with the License.
4+
// You may obtain a copy of the License at
5+
//
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
14+
//go:build darwin && !ios && cgo
15+
16+
#include <mach/mach_init.h>
17+
#include <mach/task.h>
18+
#include <mach/mach_vm.h>
19+
20+
// The compiler warns that mach/shared_memory_server.h is deprecated, and to use
21+
// mach/shared_region.h instead. But that doesn't define
22+
// SHARED_DATA_REGION_SIZE or SHARED_TEXT_REGION_SIZE, so redefine them here and
23+
// avoid a warning message when running tests.
24+
#define GLOBAL_SHARED_TEXT_SEGMENT 0x90000000U
25+
#define SHARED_DATA_REGION_SIZE 0x10000000
26+
#define SHARED_TEXT_REGION_SIZE 0x10000000
27+
28+
29+
int get_memory_info(unsigned long long *rss, unsigned long long *vsize)
30+
{
31+
// This is lightly adapted from how ps(1) obtains its memory info.
32+
// https://github.com/apple-oss-distributions/adv_cmds/blob/8744084ea0ff41ca4bb96b0f9c22407d0e48e9b7/ps/tasks.c#L109
33+
34+
kern_return_t error;
35+
task_t task = MACH_PORT_NULL;
36+
mach_task_basic_info_data_t info;
37+
mach_msg_type_number_t info_count = MACH_TASK_BASIC_INFO_COUNT;
38+
39+
error = task_info(
40+
mach_task_self(),
41+
MACH_TASK_BASIC_INFO,
42+
(task_info_t) &info,
43+
&info_count );
44+
45+
if( error != KERN_SUCCESS )
46+
{
47+
return error;
48+
}
49+
50+
*rss = info.resident_size;
51+
*vsize = info.virtual_size;
52+
53+
{
54+
vm_region_basic_info_data_64_t b_info;
55+
mach_vm_address_t address = GLOBAL_SHARED_TEXT_SEGMENT;
56+
mach_vm_size_t size;
57+
mach_port_t object_name;
58+
59+
/*
60+
* try to determine if this task has the split libraries
61+
* mapped in... if so, adjust its virtual size down by
62+
* the 2 segments that are used for split libraries
63+
*/
64+
info_count = VM_REGION_BASIC_INFO_COUNT_64;
65+
66+
error = mach_vm_region(
67+
mach_task_self(),
68+
&address,
69+
&size,
70+
VM_REGION_BASIC_INFO_64,
71+
(vm_region_info_t) &b_info,
72+
&info_count,
73+
&object_name);
74+
75+
if (error == KERN_SUCCESS) {
76+
if (b_info.reserved && size == (SHARED_TEXT_REGION_SIZE) &&
77+
*vsize > (SHARED_TEXT_REGION_SIZE + SHARED_DATA_REGION_SIZE)) {
78+
*vsize -= (SHARED_TEXT_REGION_SIZE + SHARED_DATA_REGION_SIZE);
79+
}
80+
}
81+
}
82+
83+
return 0;
84+
}

process_metrics_darwin_cgo.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
//go:build darwin && !ios && cgo
2+
3+
package metrics
4+
5+
/*
6+
int get_memory_info(unsigned long long *rss, unsigned long long *vs);
7+
*/
8+
import "C"
9+
10+
import (
11+
"fmt"
12+
)
13+
14+
func getMemory() (*memoryInfo, error) {
15+
var rss, vsize C.ulonglong
16+
17+
if err := C.get_memory_info(&rss, &vsize); err != 0 {
18+
return nil, fmt.Errorf("task_info() failed with 0x%x", int(err))
19+
}
20+
21+
return &memoryInfo{vsize: uint64(vsize), rss: uint64(rss)}, nil
22+
}

process_metrics_darwin_nocgo.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
//go:build darwin && !ios && !cgo
2+
3+
package metrics
4+
5+
func getMemory() (*memoryInfo, error) {
6+
return nil, errNotImplemented
7+
}

process_metrics_other.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
//go:build !linux && !windows && !solaris
1+
//go:build !linux && !windows && !solaris && !darwin
22

33
package metrics
44

0 commit comments

Comments
 (0)