processcollector: add network byte counters on Darwin - #2083
Conversation
Implements process_network_receive_bytes_total and process_network_transmit_bytes_total on macOS by talking to the undocumented "com.apple.network.statistics" kernel control socket (the same mechanism used by nettop/netstat), filtered to the current process's pid. No cgo or third-party dependency is required: the protocol is implemented directly on golang.org/x/sys/unix, using the same PF_SYSTEM/SYSPROTO_CONTROL socket mechanism already used elsewhere for utun. Struct layouts mirror bsd/net/ntstat.h from Apple's XNU source. Closes prometheus#1590. Signed-off-by: Dmitry Golovin <sanlm@mail.ru>
Signed-off-by: Dmitry Golovin <sanlm@mail.ru>
Signed-off-by: Dmitry Golovin <sanlm@mail.ru>
bwplotka
left a comment
There was a problem hiding this comment.
Thanks!
Added some comments, notably it works only for different provider numbers on my machine. Do you know why? 🤔
| const ( | ||
| netStatControlName = "com.apple.network.statistics" | ||
|
|
||
| nstatProviderTCPUserland uint32 = 3 |
There was a problem hiding this comment.
Running it locally on my Mac network metrics shown 0 -- AI gives this suggestion after which it seemed to work, does this seems reasonable?
AI: In prometheus/process_collector_netstat_darwin.go:L39-L40 and L132, PR #2083 queries only XNU Userland providers 3 (TCP_USERLAND) and 5 (UDP_USERLAND), which return 0 sources for standard Go BSD sockets; adding Provider 2 (TCP) and Provider 4 (UDP) enables tracking standard Go network traffic on macOS.
const (
+ nstatProviderTCP uint32 = 2
nstatProviderTCPUserland uint32 = 3
+ nstatProviderUDP uint32 = 4
nstatProviderUDPUserland uint32 = 5
)
-for _, provider := range []uint32{nstatProviderTCPUserland, nstatProviderUDPUserland} {
+for _, provider := range []uint32{nstatProviderTCP, nstatProviderTCPUserland, nstatProviderUDP, nstatProviderUDPUserland} {|
|
||
| // nstatCounts mirrors struct nstat_counts. Only the first four fields are used | ||
| // here; the rest are read (to consume the full wire message) but not exposed. | ||
| type nstatCounts struct { |
There was a problem hiding this comment.
There are tons of fields here and above we don't use it. Can we leave out only those which we use? This also applies for struct above.
AI: In prometheus/process_collector_netstat_darwin.go:L86-L107, nstatCounts defines 14 unused trailing fields (CellRxBytes ... VarRtt); since binary.Read unpacks sequentially and ignores trailing datagram bytes, truncating nstatCounts to the first 4 required fields (RxPackets, RxBytes, TxPackets, TxBytes) simplifies the codebase and decouples it from potential XNU kernel struct extensions.
type nstatCounts struct {
RxPackets uint64
RxBytes uint64
TxPackets uint64
TxBytes uint64
}| d = 0 | ||
| } | ||
| tv := unix.NsecToTimeval(d.Nanoseconds()) | ||
| if err := unix.SetsockoptTimeval(fd, unix.SOL_SOCKET, unix.SO_RCVTIMEO, &tv); err != nil { |
There was a problem hiding this comment.
Do we need to do this on every read vs on openNstatSocket?
AI: In prometheus/process_collector_netstat_darwin.go:L288, nstatRead calls unix.SetsockoptTimeval on every single datagram read inside loops; moving receive timeout initialization to openNstatSocket() (L153-L172) sets SO_RCVTIMEO once per collection cycle, eliminating redundant kernel syscalls.
func openNstatSocket() (int, error) {
...
+ tv := unix.NsecToTimeval(nstatReadTimeout.Nanoseconds())
+ if err := unix.SetsockoptTimeval(fd, unix.SOL_SOCKET, unix.SO_RCVTIMEO, &tv); err != nil {
+ unix.Close(fd)
+ return -1, fmt.Errorf("nstat: SetsockoptTimeval: %w", err)
+ }
return fd, nil
}- Trim unused trailing fields from nstatCounts, nstatMsgSrcAdded, and nstatMsgErr. binary.Read only reads sizeof(struct) bytes and leaves the rest of the datagram unread, so the Go structs only need to cover the fields we actually use. - Set SO_RCVTIMEO once in openNstatSocket instead of on every read. Did not adopt the suggestion to also query the TCP_KERNEL/UDP_KERNEL providers: verified via GET_SRC_DESC that NSTAT_FILTER_SPECIFIC_USER_BY_PID is not honored by those providers on this machine (they return every socket on the system, not just this process's), which would attribute other processes' traffic to this one. Tested with and without a local VPN active to rule that out as a factor; behavior was identical. Signed-off-by: Dmitry Golovin <sanlm@mail.ru>
|
Thanks for the review! Applied the struct-trimming and Re: the provider suggestion (adding I tested it on my machine (macOS 26.5.2, arm64) by fetching
I also suspected this might be VPN-related (I run a personal VPN locally) and re-ran the same test with it fully disconnected — same result (90MB/33MB total, still 0 pid matches on kernel providers). So it's not a VPN artifact, more likely a macOS-version-specific kernel behavior. I don't have a way to reproduce your environment to know why providers 3/5 alone showed 0 there. Could you share your macOS version? And if you get a chance: does |
d88c0d0 to
097f9ea
Compare
|
FYI: I accidentally pushed commit to your branch - forced pushed it just now to remove. Hopefully you didn't push in the mean time -- apologies. I think what you say makes sense, I saw some conflated numbers on my end too, sometimes. Not filtering correctly to PID would make sense. No local VPN. Mac version 26.5.2 Added repro and what it prints for me. AI claims net/http Go connection escapes the 2/4 providers, would that make sense? |
|
No worries about the force-push, no harm done — I confirmed the branch is back to exactly what I had ( Thanks for the repro output, that's very useful — but I think it points to something different than the AI's diagnosis. The key detail: on your machine, I'm skeptical of the AI's explanation too, for the same reason you flagged: if plain Go I tried ruling out a couple of things that could plausibly cause a machine-specific difference like this:
So on my end it's neither of those. Do you have anything similar active — any network content filter, EDR/security agent, or MDM-managed network extension? That's the main remaining variable I can think of that would explain the discrepancy between our machines. |
Summary
Closes the remaining part of #1590 by implementing
process_network_receive_bytes_totalandprocess_network_transmit_bytes_totalfor the Darwin process collector.CPU, memory (via cgo
task_info), open FDs, FD limits, and start time were already covered by #1600; network byte counters were the last gap (the descriptors were commented out with a "not implemented yet" note).Approach
Bytes are read via the undocumented
com.apple.network.statisticskernel control socket — the same interface Apple's ownnettop/netstatuse internally. No cgo and no third-party dependency is required: the protocol is implemented directly on top ofgolang.org/x/sys/unix, using the samePF_SYSTEM/SYSPROTO_CONTROLsocket mechanism already used elsewhere (e.g. by WireGuard'sutundriver on Darwin). Struct layouts mirrorbsd/net/ntstat.hfrom Apple's XNU source.At collection time, the collector:
NSTAT_MSG_TYPE_ADD_ALL_SRCS) to all TCP and UDP sources filtered to the current pid (NSTAT_FILTER_SPECIFIC_USER_BY_PID).NSTAT_MSG_TYPE_QUERY_SRC) for each reported source.nstat_rxbytes/nstat_txbytesacross sources, and closes the socket (which also cleans up the kernel-side subscriptions).This mirrors the pattern already used on Linux via
procfs'sNetstat().Test plan
go build ./...go test ./...(all packages)go test ./prometheus/... -run TestDarwinwithCGO_ENABLED=1andCGO_ENABLED=0— both pass,TestDarwinDescribeAndCollectAlignmentconfirmsdescribe()/processCollect()stay in syncgolangci-lint run ./...— 0 issuesprocess_network_receive_bytes_total/process_network_transmit_bytes_totalvalues against real socket activity on macOS 26 (arm64)Closes #1590