process: read processes from the kernel instead of perflib (#2644)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Jan-Otto Kröpke <mail@jkroepke.de>
This commit is contained in:
Jan-Otto Kröpke
2026-10-10 16:09:02 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 4ca7dc8957
commit 651daf0fee
24 changed files with 1078 additions and 2651 deletions
+28 -1
View File
@@ -1,4 +1,31 @@
# process collector
# Process collector
The collector reads all processes with one
`NtQuerySystemInformation(SystemProcessInformation)` call, not with the perflib
`Process` or PDH `Process V2` counter sets. The perflib provider (perfproc) reads
the same kernel data, so the metric values must stay identical to the `Process`
counter set. There is no counter-set selection; `--collector.process.counter-version`
and `Config.CounterVersion` were removed.
- Process names follow the perflib instance names: the image name without a
case-insensitive `.exe` suffix, other extensions such as `.scr` are kept, and
PID 0 is `Idle`. Users' include and exclude expressions depend on these names.
- The `windows_process_info` labels (owner, command line, process group ID) need
`OpenProcess` and are cached by PID and creation time, because Windows reuses
PIDs. The creation time of the opened handle is compared with the snapshot,
so a PID reused between the snapshot and the lookup is not attributed to the
old process. Failed lookups are not cached and retry on the next scrape;
access-denied results are cached as empty labels.
- Read the snapshot buffer with bounds checks against the returned length, and
keep it 8-byte aligned (`[]uint64` backing array).
To check value parity after changes, compare the snapshot against the `Process`
counter set by PID in a temporary test, for example with
`pdh.NewCollector(..., pdh.CounterTypeRaw, "Process", pdh.InstancesAll)`, including
processes with names like `a.b.exe` and `tool.scr`. Run it as a standard user
too: neither data source needs administrator rights.
## IIS application pools
IIS application pool names (`--collector.process.iis`) come from two sources.
+2 -2
View File
@@ -27,13 +27,13 @@ import (
"golang.org/x/sys/windows"
)
// workerProcessName is the perflib instance name of IIS worker processes (w3wp.exe).
// workerProcessName is the process name of IIS worker processes (w3wp.exe).
const workerProcessName = "w3wp"
// errNoAppPoolArgument reports a w3wp command line without a non-empty -ap argument.
var errNoAppPoolArgument = errors.New("command line has no -ap argument")
// isWorkerProcess reports whether the perflib process name (without instance suffix) is an IIS worker process.
// isWorkerProcess reports whether the process name is an IIS worker process.
func isWorkerProcess(name string) bool {
return strings.EqualFold(name, workerProcessName)
}
@@ -370,7 +370,6 @@ func TestBuildCloseWorkerProcess(t *testing.T) {
ProcessInclude: regexp.MustCompile(`^(?:w3wp)$`),
ProcessExclude: types.RegExpEmpty,
EnableWorkerProcess: true,
CounterVersion: 1,
})
logger := slog.New(slog.DiscardHandler)
+270 -198
View File
@@ -25,16 +25,11 @@ import (
"regexp"
"slices"
"strconv"
"strings"
"sync"
"time"
"unsafe"
"github.com/alecthomas/kingpin/v2"
"github.com/prometheus-community/windows_exporter/internal/mi"
"github.com/prometheus-community/windows_exporter/internal/pdh"
"github.com/prometheus-community/windows_exporter/internal/pdh/registry"
pdhtypes "github.com/prometheus-community/windows_exporter/internal/pdh/types"
"github.com/prometheus-community/windows_exporter/internal/types"
"github.com/prometheus/client_golang/prometheus"
"golang.org/x/sys/windows"
@@ -42,12 +37,21 @@ import (
const Name = "process"
const (
// windowsEpoch is the FILETIME of the Unix epoch, in 100ns intervals since 1601.
windowsEpoch int64 = 116444736000000000
// ticksPerSecond is the number of 100ns intervals per second.
ticksPerSecond = 1e7
// secondsPerTick converts 100ns intervals to seconds. Multiplying by it, like the perflib
// reader did, keeps the CPU time values bit-identical to the Process counter set.
secondsPerTick = 1 / ticksPerSecond
)
type Config struct {
ProcessInclude *regexp.Regexp `yaml:"include"`
ProcessExclude *regexp.Regexp `yaml:"exclude"`
EnableWorkerProcess bool `yaml:"iis"`
EnableCMDLine bool `yaml:"cmdline"`
CounterVersion uint8 `yaml:"counter-version"`
}
//nolint:gochecknoglobals
@@ -56,7 +60,6 @@ var ConfigDefaults = Config{
ProcessExclude: types.RegExpEmpty,
EnableWorkerProcess: false,
EnableCMDLine: true,
CounterVersion: 1,
}
type Collector struct {
@@ -69,10 +72,10 @@ type Collector struct {
miSession *mi.Session
workerProcessMIQueryQuery mi.Query
perfDataCollector pdhtypes.Collector[perfDataCounterValues]
perfDataObject []perfDataCounterValues
workerCh chan processWorkerRequest
workerWG sync.WaitGroup
snapshot snapshotBuffer
processes []processSnapshot
// infoCache holds the windows_process_info values of the processes of the last scrape, keyed by PID.
infoCache map[uint32]processInfo
lookupCache sync.Map
@@ -143,11 +146,6 @@ func NewWithFlags(app *kingpin.Application) *Collector {
"If enabled, the full cmdline is exposed to the windows_process_info metrics.",
).Default(strconv.FormatBool(c.config.EnableCMDLine)).BoolVar(&c.config.EnableCMDLine)
app.Flag(
"collector.process.counter-version",
"Version of the process collector to use. 1 for Process V1, 2 for Process V2, 0 for Process V2 with fallback to Process V1 if V2 is not available. Defaults to 1.",
).Default(strconv.FormatUint(uint64(c.config.CounterVersion), 10)).Uint8Var(&c.config.CounterVersion)
app.Action(func(*kingpin.ParseContext) error {
var err error
@@ -175,15 +173,9 @@ func (c *Collector) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.perfDataCollector != nil {
c.perfDataCollector.Close()
}
if c.workerCh != nil {
close(c.workerCh)
c.workerCh = nil
c.workerWG.Wait()
}
c.snapshot = snapshotBuffer{}
c.processes = nil
c.infoCache = nil
// The session belongs to the caller of Build.
c.miSession = nil
@@ -194,40 +186,7 @@ func (c *Collector) Close() error {
func (c *Collector) Build(logger *slog.Logger, miSession *mi.Session) error {
c.logger = logger.With(slog.String("collector", Name))
var err error
switch c.config.CounterVersion {
case 2:
c.perfDataCollector, err = pdh.NewCollector[perfDataCounterValues](c.logger, pdh.CounterTypeRaw, "Process V2", pdh.InstancesAll)
case 1:
c.perfDataCollector, err = registry.NewCollector[perfDataCounterValues]("Process", pdh.InstancesAll)
default:
c.perfDataCollector, err = pdh.NewCollector[perfDataCounterValues](c.logger, pdh.CounterTypeRaw, "Process V2", pdh.InstancesAll)
c.config.CounterVersion = 2
if errors.Is(err, pdh.NewPdhError(pdh.CstatusNoObject)) {
// NewCollector returns a partially built collector on errors. Release it before falling back to V1.
c.perfDataCollector.Close()
c.perfDataCollector, err = registry.NewCollector[perfDataCounterValues]("Process", pdh.InstancesAll)
c.config.CounterVersion = 1
}
c.logger.LogAttrs(context.Background(), slog.LevelDebug, fmt.Sprintf("Using process collector V%d", c.config.CounterVersion))
}
if err != nil {
return fmt.Errorf("failed to create Process V%d collector: %w", c.config.CounterVersion, err)
}
c.workerCh = make(chan processWorkerRequest, 32)
for range 4 {
c.workerWG.Add(1)
go c.collectWorker(c.workerCh)
}
c.mu = sync.RWMutex{}
c.infoCache = nil
c.lookupCache = sync.Map{}
// The flags wrap the expressions in ^(?:...)$, the defaults of [New] are [types.RegExpAny] and [types.RegExpEmpty].
@@ -379,173 +338,286 @@ func (c *Collector) buildWorkerProcessWMI(miSession *mi.Session) error {
}
func (c *Collector) Collect(ch chan<- prometheus.Metric, maxScrapeDuration time.Duration) error {
return c.collect(ch, maxScrapeDuration)
c.mu.Lock()
defer c.mu.Unlock()
var err error
c.processes, err = c.snapshot.query(c.processes)
if err != nil {
return fmt.Errorf("failed to query processes: %w", err)
}
processes := make([]*processSnapshot, 0, len(c.processes))
var workerProcessIDs []uint32
for i := range c.processes {
process := &c.processes[i]
if c.config.ProcessExclude.MatchString(process.name) || !c.config.ProcessInclude.MatchString(process.name) {
continue
}
if c.config.EnableWorkerProcess && isWorkerProcess(process.name) {
workerProcessIDs = append(workerProcessIDs, process.pid)
}
processes = append(processes, process)
}
var appPools map[uint32]string
if len(workerProcessIDs) > 0 {
appPools, err = resolveAppPools(c.logger, workerProcessIDs, workerProcessAppPool, c.workerProcessWMIQuery(maxScrapeDuration))
}
infos := c.resolveProcessInfo(processes)
for i, process := range processes {
name := process.name
if appPoolName := appPools[process.pid]; appPoolName != "" {
name = name + "_" + appPoolName
}
c.collectProcess(ch, name, process, &infos[i])
}
return err
}
// ref: https://github.com/microsoft/hcsshim/blob/8beabacfc2d21767a07c20f8dd5f9f3932dbf305/internal/uvm/stats.go#L25
func (c *Collector) getProcessInformation(pid uint32) (string, string, uint32, error) {
if pid == 0 {
return "", "", 0, nil
// workerProcessWMIQuery returns the root\WebAdministration query, or nil if it was unavailable during Build.
func (c *Collector) workerProcessWMIQuery(maxScrapeDuration time.Duration) func() ([]WorkerProcess, error) {
miSession, miQuery := c.miSession, c.workerProcessMIQueryQuery
if miSession == nil {
return nil
}
hProcess, vmReadAccess, err := c.openProcess(pid)
if err != nil {
if errors.Is(err, windows.ERROR_ACCESS_DENIED) {
return "", "", 0, nil
}
return func() ([]WorkerProcess, error) {
var workerProcesses []WorkerProcess
return "", "", 0, err
err := miSession.Query(&workerProcesses, mi.NamespaceRootWebAdministration, miQuery, maxScrapeDuration)
return workerProcesses, err
}
defer func(hProcess windows.Handle) {
if err := windows.CloseHandle(hProcess); err != nil {
c.logger.Warn("CloseHandle failed",
slog.Any("err", err),
)
}
}(hProcess)
owner, err := c.getProcessOwner(c.logger, hProcess)
if err != nil {
return "", "", 0, err
}
var (
cmdLine string
processGroupID uint32
)
if vmReadAccess {
cmdLine, processGroupID, err = c.getExtendedProcessInformation(hProcess)
if err != nil {
return "", owner, processGroupID, err
}
}
return cmdLine, owner, processGroupID, nil
}
func (c *Collector) getExtendedProcessInformation(hProcess windows.Handle) (string, uint32, error) {
// Get the process environment block (PEB) address
var pbi windows.PROCESS_BASIC_INFORMATION
// resolveAppPools maps IIS worker process IDs to application pool names.
//
// The command line of the worker process is the primary source: it needs no
// optional feature and no WMI round trip. WMI is queried at most once, for the
// worker processes whose command line couldn't be read or has no -ap argument,
// and only if queryWMI isn't nil. Unresolved processes have no entry.
func resolveAppPools(
logger *slog.Logger,
pids []uint32,
readCommandLine func(pid uint32) (string, error),
queryWMI func() ([]WorkerProcess, error),
) (map[uint32]string, error) {
appPools := make(map[uint32]string, len(pids))
retLen := uint32(unsafe.Sizeof(pbi))
if err := windows.NtQueryInformationProcess(hProcess, windows.ProcessBasicInformation, unsafe.Pointer(&pbi), retLen, &retLen); err != nil {
return "", 0, fmt.Errorf("failed to query process basic information: %w", err)
}
var unresolved []uint32
peb := windows.PEB{}
for _, pid := range pids {
appPool, err := readCommandLine(pid)
if err == nil {
appPools[pid] = appPool
err := windows.ReadProcessMemory(hProcess,
uintptr(unsafe.Pointer(pbi.PebBaseAddress)),
(*byte)(unsafe.Pointer(&peb)),
unsafe.Sizeof(peb),
nil,
)
if err != nil {
return "", 0, fmt.Errorf("failed to read process memory: %w", err)
}
continue
}
processParameters := windows.RTL_USER_PROCESS_PARAMETERS{}
err = windows.ReadProcessMemory(hProcess,
uintptr(unsafe.Pointer(peb.ProcessParameters)),
(*byte)(unsafe.Pointer(&processParameters)),
unsafe.Sizeof(processParameters),
nil,
)
if err != nil {
return "", 0, fmt.Errorf("failed to read process memory: %w", err)
}
var cmdLine string
if c.config.EnableCMDLine {
cmdLineUTF16 := make([]uint16, processParameters.CommandLine.Length)
err = windows.ReadProcessMemory(hProcess,
uintptr(unsafe.Pointer(processParameters.CommandLine.Buffer)),
(*byte)(unsafe.Pointer(&cmdLineUTF16[0])),
uintptr(processParameters.CommandLine.Length),
nil,
logger.LogAttrs(context.Background(), slog.LevelDebug, "Failed to read the IIS application pool from the worker process command line",
slog.Uint64("pid", uint64(pid)),
slog.Any("err", err),
)
if err != nil {
return "", processParameters.ProcessGroupId, fmt.Errorf("failed to read process memory: %w", err)
}
cmdLine = strings.TrimSpace(windows.UTF16ToString(cmdLineUTF16))
// OpenProcess fails with ERROR_INVALID_PARAMETER for processes that exited after the
// process snapshot. WAS no longer reports them either.
if !errors.Is(err, windows.ERROR_INVALID_PARAMETER) {
unresolved = append(unresolved, pid)
}
}
return cmdLine, processParameters.ProcessGroupId, nil
}
func (c *Collector) getProcessOwner(logger *slog.Logger, hProcess windows.Handle) (string, error) {
var tok windows.Token
if err := windows.OpenProcessToken(hProcess, windows.TOKEN_QUERY, &tok); err != nil {
if errors.Is(err, windows.ERROR_ACCESS_DENIED) {
return "", nil
}
return "", fmt.Errorf("failed to open process token: %w", err)
if len(unresolved) == 0 || queryWMI == nil {
return appPools, nil
}
defer func(tok windows.Token) {
if err := tok.Close(); err != nil {
logger.Warn("Token close failed",
slog.Any("err", err),
)
}
}(tok)
tokenUser, err := tok.GetTokenUser()
workerProcesses, err := queryWMI()
if err != nil {
return "", fmt.Errorf("failed to get token user: %w", err)
return appPools, fmt.Errorf("WMI query for collector.process.iis failed: %w", err)
}
sid := tokenUser.User.Sid.String()
var owner string
ownerVal, ok := c.lookupCache.Load(sid)
if ok {
owner, ok = ownerVal.(string)
}
if !ok {
account, domain, _, err := tokenUser.User.Sid.LookupAccount("")
if err != nil {
owner = sid
} else {
owner = fmt.Sprintf(`%s\%s`, domain, account)
byPID := make(map[uint64]string, len(workerProcesses))
for _, wp := range workerProcesses {
if _, ok := byPID[wp.ProcessId]; !ok && wp.AppPoolName != "" {
byPID[wp.ProcessId] = wp.AppPoolName
}
c.lookupCache.Store(sid, owner)
}
return owner, nil
for _, pid := range unresolved {
if appPool, ok := byPID[uint64(pid)]; ok {
appPools[pid] = appPool
}
}
return appPools, nil
}
func (c *Collector) openProcess(pid uint32) (windows.Handle, bool, error) {
// Open the process with QUERY_INFORMATION and VM_READ permissions.
hProcess, err := windows.OpenProcess(windows.PROCESS_QUERY_INFORMATION|windows.PROCESS_VM_READ, false, pid)
if err == nil {
return hProcess, true, nil
}
func (c *Collector) collectProcess(ch chan<- prometheus.Metric, name string, process *processSnapshot, info *processInfo) {
pid := strconv.FormatUint(uint64(process.pid), 10)
if !errors.Is(err, windows.ERROR_ACCESS_DENIED) {
return 0, false, fmt.Errorf("failed to open process: %w", err)
}
ch <- prometheus.MustNewConstMetric(
c.info,
prometheus.GaugeValue,
1.0,
name, pid, strconv.FormatUint(uint64(process.parentPID), 10), strconv.FormatUint(uint64(info.processGroupID), 10), info.owner, info.cmdLine,
)
if errors.Is(err, windows.Errno(0x57)) { // invalid parameter, for PIDs that don't exist
return 0, false, errors.New("process not found")
}
// Perflib reports whole seconds for the Elapsed Time counter of the Process counter set.
ch <- prometheus.MustNewConstMetric(
c.startTime,
prometheus.GaugeValue,
float64((process.createTime-windowsEpoch)/ticksPerSecond),
name, pid,
)
hProcess, err = windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid)
if err != nil {
return 0, false, fmt.Errorf("failed to open process with limited permissions: %w", err)
}
ch <- prometheus.MustNewConstMetric(
c.handleCount,
prometheus.GaugeValue,
float64(process.handleCount),
name, pid,
)
return hProcess, false, nil
ch <- prometheus.MustNewConstMetric(
c.cpuTimeTotal,
prometheus.CounterValue,
float64(process.kernelTime)*secondsPerTick,
name, pid, "privileged",
)
ch <- prometheus.MustNewConstMetric(
c.cpuTimeTotal,
prometheus.CounterValue,
float64(process.userTime)*secondsPerTick,
name, pid, "user",
)
ch <- prometheus.MustNewConstMetric(
c.ioBytesTotal,
prometheus.CounterValue,
float64(process.otherTransferCount),
name, pid, "other",
)
ch <- prometheus.MustNewConstMetric(
c.ioOperationsTotal,
prometheus.CounterValue,
float64(process.otherOperationCount),
name, pid, "other",
)
ch <- prometheus.MustNewConstMetric(
c.ioBytesTotal,
prometheus.CounterValue,
float64(process.readTransferCount),
name, pid, "read",
)
ch <- prometheus.MustNewConstMetric(
c.ioOperationsTotal,
prometheus.CounterValue,
float64(process.readOperationCount),
name, pid, "read",
)
ch <- prometheus.MustNewConstMetric(
c.ioBytesTotal,
prometheus.CounterValue,
float64(process.writeTransferCount),
name, pid, "write",
)
ch <- prometheus.MustNewConstMetric(
c.ioOperationsTotal,
prometheus.CounterValue,
float64(process.writeOperationCount),
name, pid, "write",
)
ch <- prometheus.MustNewConstMetric(
c.pageFaultsTotal,
prometheus.CounterValue,
float64(process.pageFaultCount),
name, pid,
)
ch <- prometheus.MustNewConstMetric(
c.pageFileBytes,
prometheus.GaugeValue,
float64(process.pageFileUsage),
name, pid,
)
ch <- prometheus.MustNewConstMetric(
c.poolBytes,
prometheus.GaugeValue,
float64(process.nonPagedPoolUsage),
name, pid, "nonpaged",
)
ch <- prometheus.MustNewConstMetric(
c.poolBytes,
prometheus.GaugeValue,
float64(process.pagedPoolUsage),
name, pid, "paged",
)
ch <- prometheus.MustNewConstMetric(
c.priorityBase,
prometheus.GaugeValue,
float64(process.basePriority),
name, pid,
)
ch <- prometheus.MustNewConstMetric(
c.privateBytes,
prometheus.GaugeValue,
float64(process.privatePageCount),
name, pid,
)
ch <- prometheus.MustNewConstMetric(
c.threadCount,
prometheus.GaugeValue,
float64(process.threadCount),
name, pid,
)
ch <- prometheus.MustNewConstMetric(
c.virtualBytes,
prometheus.GaugeValue,
float64(process.virtualSize),
name, pid,
)
ch <- prometheus.MustNewConstMetric(
c.workingSetPrivate,
prometheus.GaugeValue,
float64(process.workingSetPrivate),
name, pid,
)
ch <- prometheus.MustNewConstMetric(
c.workingSetPeak,
prometheus.GaugeValue,
float64(process.workingSetPeak),
name, pid,
)
ch <- prometheus.MustNewConstMetric(
c.workingSet,
prometheus.GaugeValue,
float64(process.workingSetSize),
name, pid,
)
}
@@ -93,7 +93,6 @@ func TestCollectorIISAppPool(t *testing.T) {
ProcessInclude: regexp.MustCompile(`^(?:w3wp)$`),
ProcessExclude: types.RegExpEmpty,
EnableWorkerProcess: true,
CounterVersion: 1,
})
require.Contains(t, families, "windows_process_info")
@@ -142,12 +141,10 @@ func TestCollectorWorkerProcessCommandLine(t *testing.T) {
ProcessInclude: regexp.MustCompile(`^(?:w3wp)$`),
ProcessExclude: types.RegExpEmpty,
EnableWorkerProcess: true,
CounterVersion: 1,
})
if _, ok := families["windows_process_info"]; !ok {
t.Skip("the Process counter set has no w3wp instance")
}
// The process snapshot includes suspended processes.
require.Contains(t, families, "windows_process_info", "no w3wp process in the process snapshot")
for _, metric := range families["windows_process_info"].GetMetric() {
labels := make(map[string]string)
+328
View File
@@ -0,0 +1,328 @@
// SPDX-License-Identifier: Apache-2.0
//
// Copyright 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.
//go:build windows
package process
import (
"context"
"errors"
"fmt"
"log/slog"
"runtime"
"runtime/debug"
"strings"
"sync"
"sync/atomic"
"unsafe"
"golang.org/x/sys/windows"
)
// maxInfoWorkers bounds the goroutines that open processes for new cache entries.
const maxInfoWorkers = 8
// errProcessGone reports that the PID no longer belongs to the snapshot process.
var errProcessGone = errors.New("process exited")
// processInfo holds the values of windows_process_info that are fixed for the lifetime of a process.
type processInfo struct {
// createTime identifies the process together with the PID, because Windows reuses PIDs.
createTime int64
cmdLine string
owner string
processGroupID uint32
}
// resolveProcessInfo returns the info of each process, in the order of processes.
// Cached entries are reused while the PID and creation time match. The others are
// looked up in parallel. The cache keeps only the processes passed in.
func (c *Collector) resolveProcessInfo(processes []*processSnapshot) []processInfo {
infos := make([]processInfo, len(processes))
cached := make([]bool, len(processes))
misses := make([]int, 0)
for i, process := range processes {
if info, ok := c.infoCache[process.pid]; ok && info.createTime == process.createTime {
infos[i] = info
cached[i] = true
continue
}
misses = append(misses, i)
}
if len(misses) > 0 {
var next atomic.Int64
wg := sync.WaitGroup{}
for range min(len(misses), runtime.GOMAXPROCS(0), maxInfoWorkers) {
wg.Go(func() {
for {
n := int(next.Add(1)) - 1
if n >= len(misses) {
return
}
i := misses[n]
c.lookupProcessInfo(processes[i], &infos[i], &cached[i])
}
})
}
wg.Wait()
}
cache := make(map[uint32]processInfo, len(processes))
for i, process := range processes {
if cached[i] {
cache[process.pid] = infos[i]
}
}
c.infoCache = cache
return infos
}
// lookupProcessInfo opens the process and reads its owner, command line and process group ID.
// cacheable is set only if the lookup succeeded. A panic leaves both values unset.
func (c *Collector) lookupProcessInfo(process *processSnapshot, info *processInfo, cacheable *bool) {
defer func() {
if r := recover(); r != nil {
c.logger.LogAttrs(context.Background(), slog.LevelError, "Panic while reading process information",
slog.Uint64("pid", uint64(process.pid)),
slog.Any("panic", r),
slog.String("stack", string(debug.Stack())),
)
}
}()
cmdLine, owner, processGroupID, err := c.getProcessInformation(process.pid, process.createTime)
if err != nil {
if !errors.Is(err, errProcessGone) {
c.logger.LogAttrs(context.Background(), slog.LevelDebug, "Failed to get process information",
slog.Uint64("pid", uint64(process.pid)),
slog.String("process", process.name),
slog.Any("err", err),
)
}
}
*info = processInfo{
createTime: process.createTime,
cmdLine: cmdLine,
owner: owner,
processGroupID: processGroupID,
}
*cacheable = err == nil
}
// getProcessInformation returns the command line, owner and process group ID of a process.
// createTime is checked after opening the process, so a reused PID is not attributed to the old process.
//
// ref: https://github.com/microsoft/hcsshim/blob/8beabacfc2d21767a07c20f8dd5f9f3932dbf305/internal/uvm/stats.go#L25
func (c *Collector) getProcessInformation(pid uint32, createTime int64) (string, string, uint32, error) {
if pid == 0 {
return "", "", 0, nil
}
hProcess, vmReadAccess, err := c.openProcess(pid)
if err != nil {
if errors.Is(err, windows.ERROR_ACCESS_DENIED) {
return "", "", 0, nil
}
return "", "", 0, err
}
defer func(hProcess windows.Handle) {
if err := windows.CloseHandle(hProcess); err != nil {
c.logger.Warn("CloseHandle failed",
slog.Any("err", err),
)
}
}(hProcess)
var creationTime, exitTime, kernelTime, userTime windows.Filetime
if err := windows.GetProcessTimes(hProcess, &creationTime, &exitTime, &kernelTime, &userTime); err == nil &&
createTime != 0 && int64(creationTime.HighDateTime)<<32|int64(creationTime.LowDateTime) != createTime {
return "", "", 0, errProcessGone
}
owner, err := c.getProcessOwner(c.logger, hProcess)
if err != nil {
return "", "", 0, err
}
var (
cmdLine string
processGroupID uint32
)
if vmReadAccess {
cmdLine, processGroupID, err = c.getExtendedProcessInformation(hProcess)
if err != nil {
return "", owner, processGroupID, err
}
}
return cmdLine, owner, processGroupID, nil
}
func (c *Collector) getExtendedProcessInformation(hProcess windows.Handle) (string, uint32, error) {
// Get the process environment block (PEB) address
var pbi windows.PROCESS_BASIC_INFORMATION
retLen := uint32(unsafe.Sizeof(pbi))
if err := windows.NtQueryInformationProcess(hProcess, windows.ProcessBasicInformation, unsafe.Pointer(&pbi), retLen, &retLen); err != nil {
return "", 0, fmt.Errorf("failed to query process basic information: %w", err)
}
// Minimal processes like Registry and Memory Compression have no PEB.
if pbi.PebBaseAddress == nil {
return "", 0, nil
}
peb := windows.PEB{}
err := windows.ReadProcessMemory(hProcess,
uintptr(unsafe.Pointer(pbi.PebBaseAddress)),
(*byte)(unsafe.Pointer(&peb)),
unsafe.Sizeof(peb),
nil,
)
if err != nil {
return "", 0, fmt.Errorf("failed to read process memory: %w", err)
}
if peb.ProcessParameters == nil {
return "", 0, nil
}
processParameters := windows.RTL_USER_PROCESS_PARAMETERS{}
err = windows.ReadProcessMemory(hProcess,
uintptr(unsafe.Pointer(peb.ProcessParameters)),
(*byte)(unsafe.Pointer(&processParameters)),
unsafe.Sizeof(processParameters),
nil,
)
if err != nil {
return "", 0, fmt.Errorf("failed to read process memory: %w", err)
}
var cmdLine string
// CommandLine.Length is in bytes.
if c.config.EnableCMDLine && processParameters.CommandLine.Length >= 2 && processParameters.CommandLine.Buffer != nil {
cmdLineUTF16 := make([]uint16, processParameters.CommandLine.Length/2)
err = windows.ReadProcessMemory(hProcess,
uintptr(unsafe.Pointer(processParameters.CommandLine.Buffer)),
(*byte)(unsafe.Pointer(&cmdLineUTF16[0])),
uintptr(len(cmdLineUTF16)*2),
nil,
)
if err != nil {
return "", processParameters.ProcessGroupId, fmt.Errorf("failed to read process memory: %w", err)
}
cmdLine = strings.TrimSpace(windows.UTF16ToString(cmdLineUTF16))
}
return cmdLine, processParameters.ProcessGroupId, nil
}
func (c *Collector) getProcessOwner(logger *slog.Logger, hProcess windows.Handle) (string, error) {
var tok windows.Token
if err := windows.OpenProcessToken(hProcess, windows.TOKEN_QUERY, &tok); err != nil {
if errors.Is(err, windows.ERROR_ACCESS_DENIED) {
return "", nil
}
return "", fmt.Errorf("failed to open process token: %w", err)
}
defer func(tok windows.Token) {
if err := tok.Close(); err != nil {
logger.Warn("Token close failed",
slog.Any("err", err),
)
}
}(tok)
tokenUser, err := tok.GetTokenUser()
if err != nil {
return "", fmt.Errorf("failed to get token user: %w", err)
}
sid := tokenUser.User.Sid.String()
var owner string
ownerVal, ok := c.lookupCache.Load(sid)
if ok {
owner, ok = ownerVal.(string)
}
if !ok {
account, domain, _, err := tokenUser.User.Sid.LookupAccount("")
if err != nil {
owner = sid
} else {
owner = fmt.Sprintf(`%s\%s`, domain, account)
}
c.lookupCache.Store(sid, owner)
}
return owner, nil
}
func (c *Collector) openProcess(pid uint32) (windows.Handle, bool, error) {
// Open the process with QUERY_INFORMATION and VM_READ permissions.
hProcess, err := windows.OpenProcess(windows.PROCESS_QUERY_INFORMATION|windows.PROCESS_VM_READ, false, pid)
if err == nil {
return hProcess, true, nil
}
if errors.Is(err, windows.ERROR_INVALID_PARAMETER) { // The PID does not exist anymore.
return 0, false, errProcessGone
}
if !errors.Is(err, windows.ERROR_ACCESS_DENIED) {
return 0, false, fmt.Errorf("failed to open process: %w", err)
}
hProcess, err = windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid)
if err != nil {
if errors.Is(err, windows.ERROR_INVALID_PARAMETER) {
return 0, false, errProcessGone
}
return 0, false, fmt.Errorf("failed to open process with limited permissions: %w", err)
}
return hProcess, false, nil
}
@@ -18,11 +18,15 @@
package process_test
import (
"os"
"regexp"
"strconv"
"testing"
"github.com/alecthomas/kingpin/v2"
"github.com/prometheus-community/windows_exporter/internal/collector/process"
"github.com/prometheus-community/windows_exporter/internal/utils/testutils"
"github.com/stretchr/testify/require"
)
func BenchmarkProcessCollector(b *testing.B) {
@@ -37,3 +41,40 @@ func BenchmarkProcessCollector(b *testing.B) {
func TestCollector(t *testing.T) {
testutils.TestCollector(t, process.New, nil)
}
func TestCollectorCurrentProcess(t *testing.T) {
config := process.ConfigDefaults
config.ProcessInclude = regexp.MustCompile(`^process\.test$`)
families := testutils.TestCollector(t, process.New, &config)
pid := strconv.Itoa(os.Getpid())
infos := families["windows_process_info"].GetMetric()
require.Len(t, infos, 1)
labels := map[string]string{}
for _, label := range infos[0].GetLabel() {
labels[label.GetName()] = label.GetValue()
}
require.Equal(t, "process.test", labels["process"])
require.Equal(t, pid, labels["process_id"])
require.Equal(t, strconv.Itoa(os.Getppid()), labels["creating_process_id"])
require.NotEmpty(t, labels["owner"])
require.Contains(t, labels["cmdline"], "process.test")
for _, name := range []string{
"windows_process_start_time_seconds_timestamp",
"windows_process_handles",
"windows_process_threads",
"windows_process_virtual_bytes",
"windows_process_working_set_bytes",
"windows_process_private_bytes",
} {
require.Positive(t, testutils.MetricValuesByLabel(families, name, "process_id")[pid], name)
}
cpu := families["windows_process_cpu_time_total"].GetMetric()
require.Len(t, cpu, 2)
require.NotNil(t, cpu[0].GetCounter())
}
@@ -1,380 +0,0 @@
// SPDX-License-Identifier: Apache-2.0
//
// Copyright 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.
//go:build windows
package process
import (
"context"
"errors"
"fmt"
"log/slog"
"runtime/debug"
"strconv"
"strings"
"sync"
"time"
"github.com/prometheus-community/windows_exporter/internal/mi"
"github.com/prometheus/client_golang/prometheus"
"golang.org/x/sys/windows"
)
type processWorkerRequest struct {
ch chan<- prometheus.Metric
name string
appPoolName string
performanceCounterValues perfDataCounterValues
waitGroup *sync.WaitGroup
}
func (c *Collector) collect(ch chan<- prometheus.Metric, maxScrapeDuration time.Duration) error {
err := c.perfDataCollector.Collect(&c.perfDataObject)
if err != nil {
return fmt.Errorf("failed to collect metrics: %w", err)
}
requests := make([]processWorkerRequest, 0, len(c.perfDataObject))
var workerProcessIDs []uint32
for _, process := range c.perfDataObject {
// Duplicate processes are suffixed #, and an index number. Remove those.
name, _, _ := strings.Cut(process.Name, ":") // Process V2
// Duplicate processes are suffixed #, and an index number. Remove those.
name, _, _ = strings.Cut(name, "#") // Process V1
if c.config.ProcessExclude.MatchString(name) || !c.config.ProcessInclude.MatchString(name) {
continue
}
if process.ProcessID == 0 && name != "Idle" {
c.logger.LogAttrs(context.Background(), slog.LevelDebug, "Skipping process with PID 0",
slog.String("name", name),
slog.String("process_name", process.Name),
slog.Any("process", fmt.Sprintf("%+v", process)),
)
continue
}
if c.config.EnableWorkerProcess && isWorkerProcess(name) {
workerProcessIDs = append(workerProcessIDs, uint32(process.ProcessID))
}
requests = append(requests, processWorkerRequest{
ch: ch,
name: name,
performanceCounterValues: process,
})
}
var appPools map[uint32]string
if len(workerProcessIDs) > 0 {
appPools, err = resolveAppPools(c.logger, workerProcessIDs, workerProcessAppPool, c.workerProcessWMIQuery(maxScrapeDuration))
}
wg := &sync.WaitGroup{}
for _, req := range requests {
req.appPoolName = appPools[uint32(req.performanceCounterValues.ProcessID)]
req.waitGroup = wg
wg.Add(1)
c.workerCh <- req
}
wg.Wait()
return err
}
// workerProcessWMIQuery returns the root\WebAdministration query, or nil if it was unavailable during Build.
func (c *Collector) workerProcessWMIQuery(maxScrapeDuration time.Duration) func() ([]WorkerProcess, error) {
miSession, miQuery := c.miSession, c.workerProcessMIQueryQuery
if miSession == nil {
return nil
}
return func() ([]WorkerProcess, error) {
var workerProcesses []WorkerProcess
err := miSession.Query(&workerProcesses, mi.NamespaceRootWebAdministration, miQuery, maxScrapeDuration)
return workerProcesses, err
}
}
// resolveAppPools maps IIS worker process IDs to application pool names.
//
// The command line of the worker process is the primary source: it needs no
// optional feature and no WMI round trip. WMI is queried at most once, for the
// worker processes whose command line couldn't be read or has no -ap argument,
// and only if queryWMI isn't nil. Unresolved processes have no entry.
func resolveAppPools(
logger *slog.Logger,
pids []uint32,
readCommandLine func(pid uint32) (string, error),
queryWMI func() ([]WorkerProcess, error),
) (map[uint32]string, error) {
appPools := make(map[uint32]string, len(pids))
var unresolved []uint32
for _, pid := range pids {
appPool, err := readCommandLine(pid)
if err == nil {
appPools[pid] = appPool
continue
}
logger.LogAttrs(context.Background(), slog.LevelDebug, "Failed to read the IIS application pool from the worker process command line",
slog.Uint64("pid", uint64(pid)),
slog.Any("err", err),
)
// OpenProcess fails with ERROR_INVALID_PARAMETER for processes that exited after the
// performance counter snapshot. WAS no longer reports them either.
if !errors.Is(err, windows.ERROR_INVALID_PARAMETER) {
unresolved = append(unresolved, pid)
}
}
if len(unresolved) == 0 || queryWMI == nil {
return appPools, nil
}
workerProcesses, err := queryWMI()
if err != nil {
return appPools, fmt.Errorf("WMI query for collector.process.iis failed: %w", err)
}
for _, pid := range unresolved {
for _, wp := range workerProcesses {
if wp.ProcessId == uint64(pid) && wp.AppPoolName != "" {
appPools[pid] = wp.AppPoolName
break
}
}
}
return appPools, nil
}
func (c *Collector) collectWorker(requests <-chan processWorkerRequest) {
defer c.workerWG.Done()
for req := range requests {
(func() {
defer req.waitGroup.Done()
defer func() {
if r := recover(); r != nil {
c.logger.Error("Worker panic",
slog.Any("panic", r),
slog.String("stack", string(debug.Stack())),
)
}
}()
ch := req.ch
name := req.name
data := req.performanceCounterValues
pid := uint64(data.ProcessID)
parentPID := strconv.FormatUint(uint64(data.CreatingProcessID), 10)
if req.appPoolName != "" {
name = name + "_" + req.appPoolName
}
cmdLine, processOwner, processGroupID, err := c.getProcessInformation(uint32(pid))
if err != nil {
c.logger.LogAttrs(context.Background(), slog.LevelDebug, "Failed to get process information",
slog.Uint64("pid", pid),
slog.Any("err", err),
)
}
pidString := strconv.FormatUint(pid, 10)
ch <- prometheus.MustNewConstMetric(
c.info,
prometheus.GaugeValue,
1.0,
name, pidString, parentPID, strconv.Itoa(int(processGroupID)), processOwner, cmdLine,
)
startTime := data.ElapsedTime
if c.config.CounterVersion == 2 {
// For V2, the ElapsedTime is in seconds, so we need to convert it to a timestamp.
// The start time is the current time minus the elapsed time.
startTime = float64(time.Now().UnixMicro())/1e6 - data.ElapsedTime
}
ch <- prometheus.MustNewConstMetric(
c.startTime,
prometheus.GaugeValue,
startTime,
name, pidString,
)
ch <- prometheus.MustNewConstMetric(
c.handleCount,
prometheus.GaugeValue,
data.HandleCount,
name, pidString,
)
ch <- prometheus.MustNewConstMetric(
c.cpuTimeTotal,
prometheus.CounterValue,
data.PercentPrivilegedTime,
name, pidString, "privileged",
)
ch <- prometheus.MustNewConstMetric(
c.cpuTimeTotal,
prometheus.CounterValue,
data.PercentUserTime,
name, pidString, "user",
)
ch <- prometheus.MustNewConstMetric(
c.ioBytesTotal,
prometheus.CounterValue,
data.IoOtherBytesPerSec,
name, pidString, "other",
)
ch <- prometheus.MustNewConstMetric(
c.ioOperationsTotal,
prometheus.CounterValue,
data.IoOtherOperationsPerSec,
name, pidString, "other",
)
ch <- prometheus.MustNewConstMetric(
c.ioBytesTotal,
prometheus.CounterValue,
data.IoReadBytesPerSec,
name, pidString, "read",
)
ch <- prometheus.MustNewConstMetric(
c.ioOperationsTotal,
prometheus.CounterValue,
data.IoReadOperationsPerSec,
name, pidString, "read",
)
ch <- prometheus.MustNewConstMetric(
c.ioBytesTotal,
prometheus.CounterValue,
data.IoWriteBytesPerSec,
name, pidString, "write",
)
ch <- prometheus.MustNewConstMetric(
c.ioOperationsTotal,
prometheus.CounterValue,
data.IoWriteOperationsPerSec,
name, pidString, "write",
)
ch <- prometheus.MustNewConstMetric(
c.pageFaultsTotal,
prometheus.CounterValue,
data.PageFaultsPerSec,
name, pidString,
)
ch <- prometheus.MustNewConstMetric(
c.pageFileBytes,
prometheus.GaugeValue,
data.PageFileBytes,
name, pidString,
)
ch <- prometheus.MustNewConstMetric(
c.poolBytes,
prometheus.GaugeValue,
data.PoolNonPagedBytes,
name, pidString, "nonpaged",
)
ch <- prometheus.MustNewConstMetric(
c.poolBytes,
prometheus.GaugeValue,
data.PoolPagedBytes,
name, pidString, "paged",
)
ch <- prometheus.MustNewConstMetric(
c.priorityBase,
prometheus.GaugeValue,
data.PriorityBase,
name, pidString,
)
ch <- prometheus.MustNewConstMetric(
c.privateBytes,
prometheus.GaugeValue,
data.PrivateBytes,
name, pidString,
)
ch <- prometheus.MustNewConstMetric(
c.threadCount,
prometheus.GaugeValue,
data.ThreadCount,
name, pidString,
)
ch <- prometheus.MustNewConstMetric(
c.virtualBytes,
prometheus.GaugeValue,
data.VirtualBytes,
name, pidString,
)
ch <- prometheus.MustNewConstMetric(
c.workingSetPrivate,
prometheus.GaugeValue,
data.WorkingSetPrivate,
name, pidString,
)
ch <- prometheus.MustNewConstMetric(
c.workingSetPeak,
prometheus.GaugeValue,
data.WorkingSetPeak,
name, pidString,
)
ch <- prometheus.MustNewConstMetric(
c.workingSet,
prometheus.GaugeValue,
data.WorkingSet,
name, pidString,
)
})()
}
}
@@ -1,89 +0,0 @@
// SPDX-License-Identifier: Apache-2.0
//
// Copyright 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.
//go:build windows
package process
import (
"log/slog"
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestCloseBeforeWorkerStarts(t *testing.T) {
t.Parallel()
c := New(nil)
requests := make(chan processWorkerRequest)
c.workerCh = requests
c.workerWG.Add(1)
closed := make(chan error, 1)
go func() { closed <- c.Close() }()
select {
case _, ok := <-requests:
require.False(t, ok)
case <-time.After(time.Second):
t.Fatal("Close did not close the worker channel")
}
// The worker starts after Close has set c.workerCh to nil.
go c.collectWorker(requests)
select {
case err := <-closed:
require.NoError(t, err)
case <-time.After(time.Second):
t.Fatal("Close did not wait for the delayed worker to exit")
}
require.NoError(t, c.Close())
}
func TestWorkerContinuesAfterRequestPanic(t *testing.T) {
t.Parallel()
c := New(nil)
c.logger = slog.New(slog.DiscardHandler)
c.workerCh = make(chan processWorkerRequest, 2)
c.workerWG.Add(1)
go c.collectWorker(c.workerCh)
var requests sync.WaitGroup
requests.Add(2)
for range 2 {
// Missing descriptors make metric construction panic in both requests.
c.workerCh <- processWorkerRequest{waitGroup: &requests}
}
processed := make(chan struct{})
go func() { requests.Wait(); close(processed) }()
select {
case <-processed:
case <-time.After(time.Second):
t.Fatal("worker did not recover and process the next request")
}
require.NoError(t, c.Close())
}
@@ -0,0 +1,179 @@
// SPDX-License-Identifier: Apache-2.0
//
// Copyright 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.
//go:build windows
package process
import (
"errors"
"fmt"
"strings"
"unsafe"
"golang.org/x/sys/windows"
)
// maxSnapshotAttempts bounds the retries when processes are created
// between the size query and the snapshot.
const maxSnapshotAttempts = 5
// processSnapshot is one process from SystemProcessInformation.
// The values are the same that the perflib Process counter set reads.
type processSnapshot struct {
name string
pid uint32
parentPID uint32
// createTime is the FILETIME of the process creation, in 100ns intervals since 1601.
createTime int64
// userTime and kernelTime are in 100ns intervals.
userTime int64
kernelTime int64
basePriority int32
handleCount uint32
threadCount uint32
pageFaultCount uint32
virtualSize uint64
workingSetSize uint64
workingSetPeak uint64
workingSetPrivate uint64
pagedPoolUsage uint64
nonPagedPoolUsage uint64
pageFileUsage uint64
privatePageCount uint64
readOperationCount uint64
writeOperationCount uint64
otherOperationCount uint64
readTransferCount uint64
writeTransferCount uint64
otherTransferCount uint64
}
// snapshotBuffer holds the SystemProcessInformation buffer across scrapes.
// The backing array is []uint64, so the process entries are 8-byte aligned.
type snapshotBuffer struct {
buf []uint64
}
// query returns all processes of the system with one NtQuerySystemInformation call.
func (s *snapshotBuffer) query(dst []processSnapshot) ([]processSnapshot, error) {
if len(s.buf) == 0 {
s.buf = make([]uint64, 256<<10/8)
}
var returnLength uint32
for attempt := 0; ; attempt++ {
err := windows.NtQuerySystemInformation(
windows.SystemProcessInformation,
unsafe.Pointer(&s.buf[0]),
uint32(len(s.buf)*8),
&returnLength,
)
if err == nil {
break
}
if !errors.Is(err, windows.STATUS_INFO_LENGTH_MISMATCH) || attempt >= maxSnapshotAttempts {
return dst, fmt.Errorf("NtQuerySystemInformation(SystemProcessInformation): %w", err)
}
// Processes can start before the next call. Leave room for them.
size := max(uint64(returnLength), uint64(len(s.buf))*8)
s.buf = make([]uint64, (size+size/4)/8+1)
}
return parseSystemProcessInformation(dst[:0], unsafe.Slice((*byte)(unsafe.Pointer(&s.buf[0])), len(s.buf)*8), returnLength)
}
// parseSystemProcessInformation decodes the entries of a SystemProcessInformation buffer.
// The buffer must be 8-byte aligned. The image names are copied, so dst does not reference buf.
func parseSystemProcessInformation(dst []processSnapshot, buf []byte, length uint32) ([]processSnapshot, error) {
end := min(uint64(length), uint64(len(buf)))
entrySize := uint64(unsafe.Sizeof(windows.SYSTEM_PROCESS_INFORMATION{}))
for offset := uint64(0); ; {
if offset+entrySize > end {
return dst, fmt.Errorf("SystemProcessInformation entry at offset %d exceeds the buffer length %d", offset, end)
}
entry := (*windows.SYSTEM_PROCESS_INFORMATION)(unsafe.Pointer(&buf[offset]))
var imageName string
if entry.ImageName.Length > 0 && entry.ImageName.Buffer != nil {
// The kernel stores the image name inside the buffer. Check it, so a bad entry cannot read outside of it.
nameOffset := uint64(uintptr(unsafe.Pointer(entry.ImageName.Buffer)) - uintptr(unsafe.Pointer(&buf[0])))
if nameOffset >= end || uint64(entry.ImageName.Length) > end-nameOffset {
return dst, fmt.Errorf("SystemProcessInformation image name at offset %d exceeds the buffer length %d", nameOffset, end)
}
imageName = entry.ImageName.String()
}
dst = append(dst, processSnapshot{
name: processName(uint32(entry.UniqueProcessID), imageName),
pid: uint32(entry.UniqueProcessID),
parentPID: uint32(entry.InheritedFromUniqueProcessID),
createTime: entry.CreateTime,
userTime: entry.UserTime,
kernelTime: entry.KernelTime,
basePriority: entry.BasePriority,
handleCount: entry.HandleCount,
threadCount: entry.NumberOfThreads,
pageFaultCount: entry.PageFaultCount,
virtualSize: uint64(entry.VirtualSize),
workingSetSize: uint64(entry.WorkingSetSize),
workingSetPeak: uint64(entry.PeakWorkingSetSize),
workingSetPrivate: uint64(entry.WorkingSetPrivateSize),
pagedPoolUsage: uint64(entry.QuotaPagedPoolUsage),
nonPagedPoolUsage: uint64(entry.QuotaNonPagedPoolUsage),
pageFileUsage: uint64(entry.PagefileUsage),
privatePageCount: uint64(entry.PrivatePageCount),
readOperationCount: uint64(entry.ReadOperationCount),
writeOperationCount: uint64(entry.WriteOperationCount),
otherOperationCount: uint64(entry.OtherOperationCount),
readTransferCount: uint64(entry.ReadTransferCount),
writeTransferCount: uint64(entry.WriteTransferCount),
otherTransferCount: uint64(entry.OtherTransferCount),
})
if entry.NextEntryOffset == 0 {
return dst, nil
}
offset += uint64(entry.NextEntryOffset)
}
}
// processName returns the instance name that the perflib Process counter set uses:
// the image name without a case-insensitive ".exe" suffix, and "Idle" for the System Idle Process.
// Other extensions, like ".scr", are kept.
func processName(pid uint32, imageName string) string {
if pid == 0 && imageName == "" {
return "Idle"
}
if len(imageName) > len(".exe") && strings.EqualFold(imageName[len(imageName)-len(".exe"):], ".exe") {
return imageName[:len(imageName)-len(".exe")]
}
return imageName
}
@@ -0,0 +1,195 @@
// SPDX-License-Identifier: Apache-2.0
//
// Copyright 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.
//go:build windows
package process
import (
"log/slog"
"os"
"testing"
"unsafe"
"github.com/stretchr/testify/require"
"golang.org/x/sys/windows"
)
func TestProcessName(t *testing.T) {
t.Parallel()
// The names follow the instances of the perflib Process counter set.
for _, tc := range []struct {
pid uint32
imageName string
want string
}{
{0, "", "Idle"},
{4, "System", "System"},
{100, "Registry", "Registry"},
{200, "svchost.exe", "svchost"},
{300, "EPSDNMON.EXE", "EPSDNMON"},
{400, "Microsoft.Photos.exe", "Microsoft.Photos"},
{500, "screensaver.scr", "screensaver.scr"},
{600, ".exe", ".exe"},
} {
require.Equal(t, tc.want, processName(tc.pid, tc.imageName), "pid %d image %q", tc.pid, tc.imageName)
}
}
func TestSnapshotContainsCurrentProcess(t *testing.T) {
t.Parallel()
var s snapshotBuffer
processes, err := s.query(nil)
require.NoError(t, err)
require.NotEmpty(t, processes)
self := findProcess(t, processes, uint32(os.Getpid()))
require.Equal(t, uint32(os.Getppid()), self.parentPID)
require.Equal(t, currentProcessCreateTime(t), self.createTime)
require.NotZero(t, self.threadCount)
require.NotZero(t, self.handleCount)
require.NotZero(t, self.workingSetSize)
require.NotZero(t, self.privatePageCount)
idle := findProcess(t, processes, 0)
require.Equal(t, "Idle", idle.name)
}
func TestSnapshotGrowsBuffer(t *testing.T) {
t.Parallel()
s := snapshotBuffer{buf: make([]uint64, 1)}
processes, err := s.query(nil)
require.NoError(t, err)
require.NotEmpty(t, processes)
require.Greater(t, len(s.buf), 1)
}
func TestParseSystemProcessInformationRejectsTruncatedBuffer(t *testing.T) {
t.Parallel()
entrySize := int(unsafe.Sizeof(windows.SYSTEM_PROCESS_INFORMATION{}))
buf := make([]uint64, entrySize/8+1)
data := unsafe.Slice((*byte)(unsafe.Pointer(&buf[0])), len(buf)*8)
// The first entry points to a second entry behind the end of the buffer.
(*windows.SYSTEM_PROCESS_INFORMATION)(unsafe.Pointer(&data[0])).NextEntryOffset = uint32(entrySize)
processes, err := parseSystemProcessInformation(nil, data, uint32(len(data)))
require.Error(t, err)
require.Len(t, processes, 1)
_, err = parseSystemProcessInformation(nil, data, uint32(entrySize-1))
require.Error(t, err)
}
func TestParseSystemProcessInformationRejectsImageNameOutsideBuffer(t *testing.T) {
t.Parallel()
entrySize := int(unsafe.Sizeof(windows.SYSTEM_PROCESS_INFORMATION{}))
buf := make([]uint64, entrySize/8+1)
data := unsafe.Slice((*byte)(unsafe.Pointer(&buf[0])), len(buf)*8)
other := []uint16{'a', 'b'}
entry := (*windows.SYSTEM_PROCESS_INFORMATION)(unsafe.Pointer(&data[0]))
entry.ImageName.Buffer = &other[0]
entry.ImageName.Length = 4
_, err := parseSystemProcessInformation(nil, data, uint32(len(data)))
require.Error(t, err)
// A name inside the buffer, but longer than the rest of it.
entry.ImageName.Buffer = (*uint16)(unsafe.Pointer(&data[len(data)-2]))
_, err = parseSystemProcessInformation(nil, data, uint32(len(data)))
require.Error(t, err)
}
func TestResolveProcessInfoCache(t *testing.T) {
t.Parallel()
c := New(nil)
c.logger = slog.New(slog.DiscardHandler)
var s snapshotBuffer
processes, err := s.query(nil)
require.NoError(t, err)
self := findProcess(t, processes, uint32(os.Getpid()))
exited := processSnapshot{pid: 1<<32 - 4, createTime: 1}
infos := c.resolveProcessInfo([]*processSnapshot{&self, &exited})
require.NotEmpty(t, infos[0].owner)
require.NotEmpty(t, infos[0].cmdLine)
require.Contains(t, c.infoCache, self.pid)
require.NotContains(t, c.infoCache, exited.pid, "failed lookups must be retried")
// A cached entry with the same creation time is reused without opening the process.
c.infoCache[self.pid] = processInfo{createTime: self.createTime, owner: "cached"}
infos = c.resolveProcessInfo([]*processSnapshot{&self})
require.Equal(t, "cached", infos[0].owner)
// A reused PID has another creation time and is looked up again.
c.infoCache[self.pid] = processInfo{createTime: self.createTime - 1, owner: "cached"}
infos = c.resolveProcessInfo([]*processSnapshot{&self})
require.NotEqual(t, "cached", infos[0].owner)
// Processes that are gone are evicted.
c.resolveProcessInfo(nil)
require.Empty(t, c.infoCache)
}
func TestGetProcessInformationDetectsReusedPID(t *testing.T) {
t.Parallel()
c := New(nil)
c.logger = slog.New(slog.DiscardHandler)
_, _, _, err := c.getProcessInformation(uint32(os.Getpid()), currentProcessCreateTime(t)+1)
require.ErrorIs(t, err, errProcessGone)
cmdLine, owner, _, err := c.getProcessInformation(uint32(os.Getpid()), currentProcessCreateTime(t))
require.NoError(t, err)
require.NotEmpty(t, cmdLine)
require.NotEmpty(t, owner)
}
func findProcess(t *testing.T, processes []processSnapshot, pid uint32) processSnapshot {
t.Helper()
for _, process := range processes {
if process.pid == pid {
return process
}
}
require.FailNow(t, "process not found in snapshot", "pid %d", pid)
return processSnapshot{}
}
func currentProcessCreateTime(t *testing.T) int64 {
t.Helper()
var creationTime, exitTime, kernelTime, userTime windows.Filetime
require.NoError(t, windows.GetProcessTimes(windows.CurrentProcess(), &creationTime, &exitTime, &kernelTime, &userTime))
return int64(creationTime.HighDateTime)<<32 | int64(creationTime.LowDateTime)
}
-33
View File
@@ -21,36 +21,3 @@ type WorkerProcess struct {
AppPoolName string `mi:"AppPoolName"`
ProcessId uint64 `mi:"ProcessId"`
}
type perfDataCounterValues struct {
Name string
PercentProcessorTime float64 `perfdata:"% Processor Time"`
PercentPrivilegedTime float64 `perfdata:"% Privileged Time"`
PercentUserTime float64 `perfdata:"% User Time"`
CreatingProcessID float64 `perfdata:"Creating Process ID"`
ElapsedTime float64 `perfdata:"Elapsed Time"`
HandleCount float64 `perfdata:"Handle Count"`
IoDataBytesPerSec float64 `perfdata:"IO Data Bytes/sec"`
IoDataOperationsPerSec float64 `perfdata:"IO Data Operations/sec"`
IoOtherBytesPerSec float64 `perfdata:"IO Other Bytes/sec"`
IoOtherOperationsPerSec float64 `perfdata:"IO Other Operations/sec"`
IoReadBytesPerSec float64 `perfdata:"IO Read Bytes/sec"`
IoReadOperationsPerSec float64 `perfdata:"IO Read Operations/sec"`
IoWriteBytesPerSec float64 `perfdata:"IO Write Bytes/sec"`
IoWriteOperationsPerSec float64 `perfdata:"IO Write Operations/sec"`
PageFaultsPerSec float64 `perfdata:"Page Faults/sec"`
PageFileBytesPeak float64 `perfdata:"Page File Bytes Peak"`
PageFileBytes float64 `perfdata:"Page File Bytes"`
PoolNonPagedBytes float64 `perfdata:"Pool Nonpaged Bytes"`
PoolPagedBytes float64 `perfdata:"Pool Paged Bytes"`
PriorityBase float64 `perfdata:"Priority Base"`
PrivateBytes float64 `perfdata:"Private Bytes"`
ThreadCount float64 `perfdata:"Thread Count"`
VirtualBytesPeak float64 `perfdata:"Virtual Bytes Peak"`
VirtualBytes float64 `perfdata:"Virtual Bytes"`
WorkingSetPrivate float64 `perfdata:"Working Set - Private"`
WorkingSetPeak float64 `perfdata:"Working Set Peak"`
WorkingSet float64 `perfdata:"Working Set"`
ProcessID float64 `perfdata:"Process ID" perfdata_v1:"ID Process"`
}