Skip to content

processcollector: add network byte counters on Darwin - #2083

Open
LunatikDG wants to merge 4 commits into
prometheus:mainfrom
LunatikDG:feat/darwin-network-stats
Open

processcollector: add network byte counters on Darwin#2083
LunatikDG wants to merge 4 commits into
prometheus:mainfrom
LunatikDG:feat/darwin-network-stats

Conversation

@LunatikDG

Copy link
Copy Markdown

Summary

Closes the remaining part of #1590 by implementing process_network_receive_bytes_total and process_network_transmit_bytes_total for 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.statistics kernel control socket — the same interface Apple's own nettop/netstat use internally. No cgo and no third-party dependency is required: the protocol is implemented directly on top of golang.org/x/sys/unix, using the same PF_SYSTEM/SYSPROTO_CONTROL socket mechanism already used elsewhere (e.g. by WireGuard's utun driver on Darwin). Struct layouts mirror bsd/net/ntstat.h from Apple's XNU source.

At collection time, the collector:

  1. Opens a control socket and subscribes (NSTAT_MSG_TYPE_ADD_ALL_SRCS) to all TCP and UDP sources filtered to the current pid (NSTAT_FILTER_SPECIFIC_USER_BY_PID).
  2. Queries counters (NSTAT_MSG_TYPE_QUERY_SRC) for each reported source.
  3. Sums nstat_rxbytes/nstat_txbytes across sources, and closes the socket (which also cleans up the kernel-side subscriptions).

This mirrors the pattern already used on Linux via procfs's Netstat().

Test plan

  • go build ./...
  • go test ./... (all packages)
  • go test ./prometheus/... -run TestDarwin with CGO_ENABLED=1 and CGO_ENABLED=0 — both pass, TestDarwinDescribeAndCollectAlignment confirms describe()/processCollect() stay in sync
  • golangci-lint run ./... — 0 issues
  • Manually verified non-zero process_network_receive_bytes_total / process_network_transmit_bytes_total values against real socket activity on macOS 26 (arm64)

Closes #1590

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 bwplotka left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@LunatikDG

Copy link
Copy Markdown
Author

Thanks for the review!

Applied the struct-trimming and SO_RCVTIMEO-placement suggestions — both are safe: binary.Read doesn't need the target struct to cover the whole message (trailing bytes are simply left unread), and setting the timeout once on the socket instead of on every read is a nice simplification. Pushed both.

Re: the provider suggestion (adding NSTAT_PROVIDER_TCP_KERNEL/UDP_KERNEL alongside the userland ones) — I don't think we should merge that one, it reproduces a real bug.

I tested it on my machine (macOS 26.5.2, arm64) by fetching GET_SRC_DESC for every source the kernel providers returned and scanning the descriptor bytes for our own pid:

  • TCP_KERNEL (2): 193 sources, 0 matched our pid
  • UDP_KERNEL (4): 71 sources, 0 matched our pid
  • TCP_USERLAND (3): 5 sources, correctly scoped
  • UDP_USERLAND (5): 0 sources

NSTAT_FILTER_SPECIFIC_USER_BY_PID is simply not honored by the kernel providers here — they return every TCP/UDP socket on the system. Summing their counters inflated the metric from ~146KB to ~80MB, i.e. other processes' traffic gets attributed to ours. That's a correctness/privacy regression, not a fix for the zero-values issue.

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 GET_SRC_DESC on the kernel-provider sources on your machine actually resolve to your own pid? If the filter genuinely works correctly on your OS version, we'd need version-gated behavior rather than unconditionally querying both — but if it's broken there too, unioning kernel+userland isn't safe on any version and we should leave this as a known limitation instead.

@bwplotka
bwplotka force-pushed the feat/darwin-network-stats branch from d88c0d0 to 097f9ea Compare August 5, 2026 17:26
@bwplotka

bwplotka commented Aug 5, 2026

Copy link
Copy Markdown
Member

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?

@LunatikDG

Copy link
Copy Markdown
Author

No worries about the force-push, no harm done — I confirmed the branch is back to exactly what I had (097f9ea) before your push.

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, TCP_USERLAND/UDP_USERLAND return zero sources total, system-wide — not "wrong pid", but empty. That's a different failure mode than what I was debugging. And since we're on the same macOS version (26.5.2), the "OS version" theory I floated doesn't hold either.

I'm skeptical of the AI's explanation too, for the same reason you flagged: if plain Go net/net/http sockets only ever registered under TCP_KERNEL/UDP_KERNEL and never under the userland providers, my own test traffic shouldn't have shown up under TCP_USERLAND either — but it does, consistently, correctly scoped to my pid.

I tried ruling out a couple of things that could plausibly cause a machine-specific difference like this:

  • VPN: tested with my personal VPN both on and off — no change.
  • Network content filter: I had a Check Point Firewall network extension (com.checkpoint.fw.filter, endpoint-security-style NEFilterDataProvider) installed. Removed it entirely and re-ran the test — no change either. Kernel providers still return ~500+ system-wide sources ignoring the pid filter, userland providers still work correctly and stay scoped to my process.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

processcollector: Add support for Darwin platform.

2 participants