process: read the IIS application pool from the w3wp command line (#2638)

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 15:44:24 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent e58e4763e2
commit 4ca7dc8957
8 changed files with 1248 additions and 46 deletions
+15 -4
View File
@@ -33,7 +33,8 @@ metrics.
### `--collector.process.iis`
Enables IIS process name queries. IIS process names are combined with their app pool name to form the `process` label.
Appends the IIS application pool name to the process name of IIS worker processes (`w3wp`) to form the `process` label.
See [IIS worker processes](#iis-worker-processes).
Disabled by default, and can be enabled with `--collector.process.iis`. NOTE: Just plain parameter without `true`.
@@ -63,11 +64,21 @@ To specify multiple names, use the pipe `|` character:
```
This will match all processes named `firefox`, `FIREFOX` or `chrome` .
## IIS Worker processes
## IIS worker processes
The process collector also queries the `root\\WebAdministration` WMI namespace to check for running IIS workers. If it successfully retrieves a list from this namespace, it will append the name of the worker's application pool to the corresponding process. include/exclude matching occurs before this name is appended, so you don't have to take this name in consideration when writing your expression.
With `--collector.process.iis`, the collector appends the application pool name to the name of each IIS worker process (`w3wp`).
Include and exclude matching happens before the name is appended, so your expressions don't need to account for it.
Note that this specific feature **only works** if the [IIS Management Scripts and Tools](https://learn.microsoft.com/en-us/iis/manage/scripting/managing-sites-with-the-iis-wmi-provider) are installed. If they are not installed then all worker processes return as just `w3wp`.
The collector reads the application pool from the `-ap` argument of the worker process command line.
The Windows Process Activation Service (WAS) starts every worker process with this argument.
Reading the command line needs Windows Server 2012 R2 or later, and access to the worker process.
The exporter running as `LocalSystem` has that access.
If the command line can't be read or has no `-ap` argument, the collector falls back to the `WorkerProcess` class in the `root\WebAdministration` WMI namespace.
That namespace needs the optional [IIS Management Scripts and Tools](https://learn.microsoft.com/iis/manage/scripting/managing-sites-with-the-iis-wmi-provider) feature (`Web-Scripting-Tools`).
The collector doesn't query WMI while every command line has an application pool.
A worker process whose application pool isn't known from either source keeps the name `w3wp`.
### Example
+20
View File
@@ -0,0 +1,20 @@
# process collector
IIS application pool names (`--collector.process.iis`) come from two sources.
- Primary: the `-ap "<pool>"` argument of each `w3wp` command line, read with
`NtQueryInformationProcess(ProcessCommandLineInformation)`. It needs only
`PROCESS_QUERY_LIMITED_INFORMATION` and Windows 8.1 or Server 2012 R2.
- Fallback: `WorkerProcess` in `root\WebAdministration`, used only when it
answered during `Build` and a command line can't be read or has no `-ap`.
It needs the optional IIS Management Scripts and Tools feature
(`Web-Scripting-Tools`), and asks WAS through RSCA on every query.
- `splitCommandLine` follows `CommandLineToArgvW`. `FuzzSplitCommandLine`
compares it with `windows.DecomposeCommandLine`; run it after parser changes
with `go test -run '^$' -fuzz FuzzSplitCommandLine ./internal/collector/process/`.
- Without IIS, `TestCollectorWorkerProcessCommandLine` starts a suspended copy
of the test binary named `w3wp.exe` to check the published label. The IIS
tests compare against WMI when available and against the
`IIS APPPOOL\<pool>` process owner.
- Don't create or change IIS application pools on a development machine to
test edge cases; use suspended processes with crafted command lines.
+276
View File
@@ -0,0 +1,276 @@
// 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"
"unicode/utf16"
"unsafe"
"golang.org/x/sys/windows"
)
// workerProcessName is the perflib instance 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.
func isWorkerProcess(name string) bool {
return strings.EqualFold(name, workerProcessName)
}
// workerProcessAppPool returns the application pool of the IIS worker process pid.
//
// The Windows Process Activation Service (WAS) starts every worker process as
// `w3wp.exe -ap "<pool>" ...`. The pool name is read from the command line of
// the process. It opens the process with PROCESS_QUERY_LIMITED_INFORMATION only.
func workerProcessAppPool(pid uint32) (string, error) {
cmdLine, err := queryProcessCommandLine(pid)
if err != nil {
return "", err
}
pool, ok := appPoolFromCommandLine(cmdLine)
if !ok {
return "", errNoAppPoolArgument
}
return pool, nil
}
// queryProcessCommandLine returns the command line of process pid.
//
// It uses NtQueryInformationProcess(ProcessCommandLineInformation), which is
// available since Windows 8.1 and Windows Server 2012 R2. Earlier versions
// return STATUS_INVALID_INFO_CLASS. Unlike reading the PEB, it needs only
// PROCESS_QUERY_LIMITED_INFORMATION, not PROCESS_VM_READ.
func queryProcessCommandLine(pid uint32) (string, error) {
hProcess, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid)
if err != nil {
return "", fmt.Errorf("OpenProcess: %w", err)
}
defer func() {
_ = windows.CloseHandle(hProcess)
}()
return processCommandLine(hProcess)
}
// unicodeStringHeader mirrors UNICODE_STRING. Buffer is kept as an address
// because it points into the buffer returned by the kernel.
type unicodeStringHeader struct {
Length uint16
MaximumLength uint16
Buffer uintptr
}
func processCommandLine(hProcess windows.Handle) (string, error) {
// UNICODE_STRING header plus a typical w3wp command line.
size := uint32(1024)
var err error
for range 4 {
// []uint64 keeps the UNICODE_STRING header pointer-aligned.
buf := make([]uint64, (size+7)/8)
bufSize := uint32(len(buf) * 8)
retLen := uint32(0)
err = windows.NtQueryInformationProcess(hProcess, windows.ProcessCommandLineInformation, unsafe.Pointer(&buf[0]), bufSize, &retLen)
if err == nil {
return decodeUnicodeString(buf)
}
if !errors.Is(err, windows.STATUS_INFO_LENGTH_MISMATCH) &&
!errors.Is(err, windows.STATUS_BUFFER_TOO_SMALL) &&
!errors.Is(err, windows.STATUS_BUFFER_OVERFLOW) {
break
}
if retLen <= bufSize {
break
}
size = retLen
}
return "", fmt.Errorf("NtQueryInformationProcess(ProcessCommandLineInformation): %w", err)
}
// decodeUnicodeString decodes the UNICODE_STRING that the kernel placed at the
// start of buf. Its Buffer must point into buf.
func decodeUnicodeString(buf []uint64) (string, error) {
base := unsafe.Pointer(&buf[0])
header := (*unicodeStringHeader)(base)
if header.Length == 0 {
return "", nil
}
bufSize := uintptr(len(buf)) * 8
offset := header.Buffer - uintptr(base)
if header.Buffer < uintptr(base) || offset < unsafe.Sizeof(*header) || offset > bufSize ||
uintptr(header.Length) > bufSize-offset || header.Length%2 != 0 {
return "", fmt.Errorf("UNICODE_STRING with length %d at offset %d does not fit the %d byte buffer", header.Length, offset, bufSize)
}
data := unsafe.Slice((*uint16)(unsafe.Add(base, offset)), header.Length/2)
return string(utf16.Decode(data)), nil
}
// appPoolFromCommandLine returns the value of the first -ap argument of a w3wp
// command line. Arguments are split with the CommandLineToArgvW rules.
//
// Application pool names can't contain '"' or '\', so the quoting WAS applies
// is unambiguous for every valid pool name.
func appPoolFromCommandLine(cmdLine string) (string, bool) {
args := splitCommandLine(cmdLine)
// args[0] is the program name.
for i := 1; i < len(args)-1; i++ {
if strings.EqualFold(args[i], "-ap") {
return args[i+1], args[i+1] != ""
}
}
return "", false
}
func isCommandLineSpace(c byte) bool {
return c == ' ' || c == '\t'
}
// splitCommandLine splits a command line like CommandLineToArgvW does.
//
// The program name ends at the next space or control character, which is
// consumed, or, if it starts with a double quote, at the next double quote;
// backslashes are literal in it. In the other arguments:
// - spaces and tabs outside double quotes separate arguments,
// - 2n backslashes followed by a double quote produce n backslashes, and the
// double quote starts or ends a quoted part,
// - 2n+1 backslashes followed by a double quote produce n backslashes and a
// literal double quote,
// - backslashes not followed by a double quote are literal,
// - inside a quoted part, every third consecutive double quote is literal.
//
// The rules only treat ASCII bytes specially, so the input is processed as
// UTF-8 bytes without decoding runes.
func splitCommandLine(cmdLine string) []string {
if cmdLine == "" {
return nil
}
var (
args []string
i int
)
if cmdLine[0] == '"' {
end := strings.IndexByte(cmdLine[1:], '"')
if end < 0 {
return []string{cmdLine[1:]}
}
args = append(args, cmdLine[1:end+1])
i = end + 2
} else {
// Any control character or space ends an unquoted program name, and is consumed.
for i < len(cmdLine) && cmdLine[i] > ' ' {
i++
}
args = append(args, cmdLine[:i])
i++
}
for i < len(cmdLine) && isCommandLineSpace(cmdLine[i]) {
i++
}
var (
arg []byte
inArg bool
quotes int
backslashes int
)
for i < len(cmdLine) {
c := cmdLine[i]
switch {
case isCommandLineSpace(c) && quotes == 0:
args = append(args, string(arg))
arg = arg[:0]
inArg = false
backslashes = 0
for i < len(cmdLine) && isCommandLineSpace(cmdLine[i]) {
i++
}
continue
case c == '\\':
arg = append(arg, c)
backslashes++
case c == '"':
if backslashes%2 == 0 {
// 2n backslashes and a quote: n backslashes, the quote toggles quoting.
arg = arg[:len(arg)-backslashes/2]
quotes++
} else {
// 2n+1 backslashes and a quote: n backslashes and a literal quote.
arg = append(arg[:len(arg)-backslashes/2-1], '"')
}
backslashes = 0
for i+1 < len(cmdLine) && cmdLine[i+1] == '"' {
i++
quotes++
if quotes == 3 {
arg = append(arg, '"')
quotes = 0
}
}
if quotes == 2 {
quotes = 0
}
default:
arg = append(arg, c)
backslashes = 0
}
inArg = true
i++
}
if inArg {
args = append(args, string(arg))
}
return args
}
+416
View File
@@ -0,0 +1,416 @@
// 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 (
"fmt"
"log/slog"
"os"
"regexp"
"strings"
"testing"
"unicode/utf8"
"unsafe"
"github.com/prometheus-community/windows_exporter/internal/mi"
"github.com/prometheus-community/windows_exporter/internal/types"
"github.com/stretchr/testify/require"
"golang.org/x/sys/windows"
)
// A command line as WAS starts IIS 10 worker processes.
const wasCommandLine = `c:\windows\system32\inetsrv\w3wp.exe -ap "DefaultAppPool" -v "v4.0" -l "webengine4.dll" -a \\.\pipe\iisipm5f5e2b1c-8d6a-4b2e-9a0c-3d1b2f4e6a7c -h "C:\inetpub\temp\apppools\DefaultAppPool\DefaultAppPool.config" -w "" -m 0 -t 20 -ta 0`
//nolint:gochecknoglobals
var commandLineTests = []struct {
name string
cmdLine string
args []string
}{
{"empty", ``, nil},
{"program only", `w3wp.exe`, []string{`w3wp.exe`}},
{"leading space", ` a b`, []string{``, `a`, `b`}},
{"whitespace only", " \t ", []string{``}},
{"trailing space", `a b `, []string{`a`, `b`}},
{"tabs", "a\tb\t\tc", []string{`a`, `b`, `c`}},
{"quoted program", `"C:\Program Files\w3wp.exe" -ap x`, []string{`C:\Program Files\w3wp.exe`, `-ap`, `x`}},
{"quoted program backslash", `"C:\dir\" x`, []string{`C:\dir\`, `x`}},
{"quoted program unterminated", `"C:\dir x`, []string{`C:\dir x`}},
{"quoted program followed by text", `"a"b c`, []string{`a`, `b`, `c`}},
{"program backslash quote", `a\"b c`, []string{`a\"b`, `c`}},
{"empty quoted argument", `a "" b`, []string{`a`, ``, `b`}},
{"trailing empty quoted argument", `a ""`, []string{`a`, ``}},
{"quoted spaces", `a "b c" d`, []string{`a`, `b c`, `d`}},
{"embedded quotes", `a b"c d"e`, []string{`a`, `bc de`}},
{"unterminated quote", `a "b c`, []string{`a`, `b c`}},
{"escaped quote", `a b\"c`, []string{`a`, `b"c`}},
{"escaped backslash before quote", `a "b\\" c`, []string{`a`, `b\`, `c`}},
{"three backslashes before quote", `a b\\\"c`, []string{`a`, `b\"c`}},
{"backslashes without quote", `a \\server\share\ b`, []string{`a`, `\\server\share\`, `b`}},
{"double quote in quoted part", `a "b""c" d`, []string{`a`, `b"c d`}},
{"three quotes in quoted part", `a "b"""c d`, []string{`a`, `b"c d`}},
{"control character ends program", "a\vb c", []string{`a`, `b`, `c`}},
{"control character after program", "a \vb", []string{`a`, "\vb"}},
{"control character after quoted program", "\"a\"\vb", []string{`a`, "\vb"}},
{"control character in argument", "a b\vc\nd", []string{`a`, "b\vc\nd"}},
{"unicode spaces", "a b c d", []string{"a b", "c d"}},
{"three quotes", `a """b c`, []string{`a`, `"b`, `c`}},
{"unicode", `a "Café Пул ✓ Ωμέγα" b`, []string{`a`, `Café Пул ✓ Ωμέγα`, `b`}},
{"was command line", wasCommandLine, []string{
`c:\windows\system32\inetsrv\w3wp.exe`, `-ap`, `DefaultAppPool`, `-v`, `v4.0`, `-l`, `webengine4.dll`,
`-a`, `\\.\pipe\iisipm5f5e2b1c-8d6a-4b2e-9a0c-3d1b2f4e6a7c`, `-h`, `C:\inetpub\temp\apppools\DefaultAppPool\DefaultAppPool.config`,
`-w`, ``, `-m`, `0`, `-t`, `20`, `-ta`, `0`,
}},
}
func TestSplitCommandLine(t *testing.T) {
t.Parallel()
for _, tc := range commandLineTests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
require.Equal(t, tc.args, splitCommandLine(tc.cmdLine))
})
}
}
// splitCommandLine must split like CommandLineToArgvW, which is the reference implementation.
func TestSplitCommandLineMatchesCommandLineToArgvW(t *testing.T) {
t.Parallel()
for _, tc := range commandLineTests {
if tc.cmdLine == "" {
// CommandLineToArgvW returns the path of the current executable for an empty command line.
continue
}
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
requireCommandLineToArgvW(t, tc.cmdLine)
})
}
}
func TestAppPoolFromCommandLine(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
cmdLine string
pool string
ok bool
}{
{"was command line", wasCommandLine, "DefaultAppPool", true},
{"spaces", `w3wp.exe -ap "Classic .NET AppPool" -v "v2.0"`, "Classic .NET AppPool", true},
{"unicode", `w3wp.exe -ap "Café Пул ✓ Ωμέγα 🚀" -v "v4.0"`, "Café Пул ✓ Ωμέγα 🚀", true},
{"unquoted", `w3wp.exe -ap DefaultAppPool`, "DefaultAppPool", true},
{"tab separated", "w3wp.exe\t-ap\t\"Pool\"", "Pool", true},
{"upper case flag", `w3wp.exe -AP "Pool"`, "Pool", true},
{"not first argument", `w3wp.exe -debug -ap "Pool" -v "v4.0"`, "Pool", true},
{"first of duplicates", `w3wp.exe -ap "First" -ap "Second"`, "First", true},
{"quoted program with spaces", `"C:\Program Files\w3wp.exe" -ap "Pool"`, "Pool", true},
{"embedded quotes", `w3wp.exe -ap Default"App Pool"`, "DefaultApp Pool", true},
{"escaped quote", `w3wp.exe -ap "a\"b"`, `a"b`, true},
{"trailing backslash", `w3wp.exe -ap "a\\"`, `a\`, true},
{"literal backslash", `w3wp.exe -ap "a\b"`, `a\b`, true},
{"missing", `w3wp.exe -v "v4.0"`, "", false},
{"no arguments", `c:\windows\system32\inetsrv\w3wp.exe`, "", false},
{"empty command line", ``, "", false},
{"missing value", `w3wp.exe -v "v4.0" -ap`, "", false},
{"empty value", `w3wp.exe -ap "" -v "v4.0"`, "", false},
{"flag inside quotes", `w3wp.exe "-ap Pool"`, "", false},
{"flag prefix", `w3wp.exe -apx "Pool"`, "", false},
{"slash flag", `w3wp.exe /ap "Pool"`, "", false},
{"program named -ap", `-ap Pool`, "", false},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
pool, ok := appPoolFromCommandLine(tc.cmdLine)
require.Equal(t, tc.ok, ok)
require.Equal(t, tc.pool, pool)
})
}
}
func FuzzSplitCommandLine(f *testing.F) {
for _, tc := range commandLineTests {
f.Add(tc.cmdLine)
}
f.Add(`w3wp.exe -ap "a\\\"b" -ap`)
f.Add(`"" "" ""`)
f.Add(`a """"""" b`)
f.Add("a\v\r\nb\u00a0c\u3000d")
f.Fuzz(func(t *testing.T, cmdLine string) {
// CommandLineToArgvW takes NUL-terminated UTF-16 and returns the executable path for an empty command line.
if cmdLine == "" || strings.ContainsRune(cmdLine, 0) || !utf8.ValidString(cmdLine) {
t.Skip()
}
args := requireCommandLineToArgvW(t, cmdLine)
var (
want string
wantOK bool
)
for i := 1; i < len(args)-1; i++ {
if strings.EqualFold(args[i], "-ap") {
want, wantOK = args[i+1], args[i+1] != ""
break
}
}
pool, ok := appPoolFromCommandLine(cmdLine)
require.Equal(t, wantOK, ok)
require.Equal(t, want, pool)
})
}
func requireCommandLineToArgvW(t *testing.T, cmdLine string) []string {
t.Helper()
want, err := windows.DecomposeCommandLine(cmdLine)
require.NoError(t, err)
got := splitCommandLine(cmdLine)
if len(want) == 0 {
require.Empty(t, got, "command line %q", cmdLine)
} else {
require.Equal(t, want, got, "command line %q", cmdLine)
}
return got
}
// The command line is read from a process that never ran, with an argument
// list as WAS passes it, so no IIS installation is needed.
func TestWorkerProcessAppPool(t *testing.T) {
t.Parallel()
executable, err := os.Executable()
require.NoError(t, err)
const pool = "Café Пул ✓ Ωμέγα 🚀"
cmdLine := `"` + executable + `" -ap "` + pool + `" -v "v4.0" -a \\.\pipe\iisipm -w "" -m 0`
appName, err := windows.UTF16PtrFromString(executable)
require.NoError(t, err)
cmdLineUTF16, err := windows.UTF16FromString(cmdLine)
require.NoError(t, err)
startupInfo := windows.StartupInfo{Cb: uint32(unsafe.Sizeof(windows.StartupInfo{}))}
var processInfo windows.ProcessInformation
// CREATE_SUSPENDED: the process parameters exist, but the test binary never runs.
require.NoError(t, windows.CreateProcess(appName, &cmdLineUTF16[0], nil, nil, false,
windows.CREATE_SUSPENDED|windows.CREATE_NO_WINDOW, nil, nil, &startupInfo, &processInfo))
t.Cleanup(func() {
require.NoError(t, windows.TerminateProcess(processInfo.Process, 1))
require.NoError(t, windows.CloseHandle(processInfo.Thread))
require.NoError(t, windows.CloseHandle(processInfo.Process))
})
pid := processInfo.ProcessId
got, err := queryProcessCommandLine(pid)
require.NoError(t, err)
require.Equal(t, cmdLine, got)
gotPool, err := workerProcessAppPool(pid)
require.NoError(t, err)
require.Equal(t, pool, gotPool)
}
func TestWorkerProcessAppPoolWithoutArgument(t *testing.T) {
t.Parallel()
_, err := workerProcessAppPool(windows.GetCurrentProcessId())
require.ErrorIs(t, err, errNoAppPoolArgument)
}
func TestQueryProcessCommandLine(t *testing.T) {
t.Parallel()
got, err := queryProcessCommandLine(windows.GetCurrentProcessId())
require.NoError(t, err)
require.Equal(t, windows.UTF16PtrToString(windows.GetCommandLine()), got)
}
func TestResolveAppPools(t *testing.T) {
t.Parallel()
logger := slog.New(slog.DiscardHandler)
commandLines := func(results map[uint32]error) func(uint32) (string, error) {
return func(pid uint32) (string, error) {
if err := results[pid]; err != nil {
return "", err
}
return fmt.Sprintf("native-%d", pid), nil
}
}
wmiResult := []WorkerProcess{
{ProcessId: 4, AppPoolName: "wmi-4"},
{ProcessId: 8, AppPoolName: "wmi-8"},
{ProcessId: 12, AppPoolName: ""},
}
t.Run("command line only", func(t *testing.T) {
t.Parallel()
pools, err := resolveAppPools(logger, []uint32{4, 8}, commandLines(nil), func() ([]WorkerProcess, error) {
t.Fatal("WMI must not be queried when every command line has the pool")
return nil, nil
})
require.NoError(t, err)
require.Equal(t, map[uint32]string{4: "native-4", 8: "native-8"}, pools)
})
t.Run("WMI fallback", func(t *testing.T) {
t.Parallel()
calls := 0
pools, err := resolveAppPools(logger, []uint32{4, 8, 12, 16}, commandLines(map[uint32]error{
8: windows.ERROR_ACCESS_DENIED,
12: errNoAppPoolArgument,
16: windows.STATUS_INVALID_INFO_CLASS,
}), func() ([]WorkerProcess, error) {
calls++
return wmiResult, nil
})
require.NoError(t, err)
require.Equal(t, 1, calls)
// An empty WMI pool name and a process WMI doesn't know get no label.
require.Equal(t, map[uint32]string{4: "native-4", 8: "wmi-8"}, pools)
})
t.Run("exited process", func(t *testing.T) {
t.Parallel()
pools, err := resolveAppPools(logger, []uint32{4, 8}, commandLines(map[uint32]error{
8: fmt.Errorf("OpenProcess: %w", windows.ERROR_INVALID_PARAMETER),
}), func() ([]WorkerProcess, error) {
t.Fatal("WMI must not be queried for exited processes")
return nil, nil
})
require.NoError(t, err)
require.Equal(t, map[uint32]string{4: "native-4"}, pools)
})
t.Run("without WMI", func(t *testing.T) {
t.Parallel()
pools, err := resolveAppPools(logger, []uint32{4, 8}, commandLines(map[uint32]error{
8: windows.ERROR_ACCESS_DENIED,
}), nil)
require.NoError(t, err)
require.Equal(t, map[uint32]string{4: "native-4"}, pools)
})
t.Run("WMI error", func(t *testing.T) {
t.Parallel()
wmiErr := windows.ERROR_TIMEOUT
pools, err := resolveAppPools(logger, []uint32{4, 8}, commandLines(map[uint32]error{
8: windows.ERROR_ACCESS_DENIED,
}), func() ([]WorkerProcess, error) {
return nil, wmiErr
})
require.ErrorIs(t, err, wmiErr)
require.Equal(t, map[uint32]string{4: "native-4"}, pools)
})
}
// Grafana Alloy builds and closes collectors repeatedly. Without
// root\WebAdministration, Build must succeed and use the command line only.
func TestBuildCloseWorkerProcess(t *testing.T) {
t.Parallel()
miApp, err := mi.ApplicationInitialize()
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, miApp.Close()) })
miSession, err := miApp.NewSession(nil)
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, miSession.Close()) })
c := New(&Config{
ProcessInclude: regexp.MustCompile(`^(?:w3wp)$`),
ProcessExclude: types.RegExpEmpty,
EnableWorkerProcess: true,
CounterVersion: 1,
})
logger := slog.New(slog.DiscardHandler)
for _, session := range []*mi.Session{nil, miSession, nil} {
require.NoError(t, c.Build(logger, session))
if session == nil {
require.Nil(t, c.miSession)
}
require.NoError(t, c.Close())
require.NoError(t, c.Close())
require.Nil(t, c.miSession)
}
}
// The command line source costs one OpenProcess and one NtQueryInformationProcess per worker process.
func BenchmarkWorkerProcessAppPool(b *testing.B) {
pid := windows.GetCurrentProcessId()
for b.Loop() {
cmdLine, err := queryProcessCommandLine(pid)
require.NoError(b, err)
appPoolFromCommandLine(cmdLine)
}
}
func BenchmarkAppPoolFromCommandLine(b *testing.B) {
for b.Loop() {
appPoolFromCommandLine(wasCommandLine)
}
}
// Errors must be the native Windows error, so callers can tell exited processes from denied access.
func TestQueryProcessCommandLineMissingProcess(t *testing.T) {
t.Parallel()
// Process IDs are multiples of four and far below this value.
_, err := queryProcessCommandLine(0xFFFFFFFC)
require.ErrorIs(t, err, windows.ERROR_INVALID_PARAMETER)
}
+24
View File
@@ -0,0 +1,24 @@
// 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
// WorkerProcessAppPool exposes the command line source to the IIS tests, which
// must live in process_test because testutils imports the collector registry.
//
//nolint:gochecknoglobals
var WorkerProcessAppPool = workerProcessAppPool
+43 -19
View File
@@ -64,6 +64,8 @@ type Collector struct {
logger *slog.Logger
// miSession is set when root\WebAdministration answered during Build. It's
// the fallback for worker processes whose command line can't be read.
miSession *mi.Session
workerProcessMIQueryQuery mi.Query
@@ -133,7 +135,7 @@ func NewWithFlags(app *kingpin.Application) *Collector {
app.Flag(
"collector.process.iis",
"Enable IIS collectWorker process name queries. May cause the collector to leak memory.",
"Append the IIS application pool name to the process name of IIS worker processes (w3wp).",
).Default(strconv.FormatBool(c.config.EnableWorkerProcess)).BoolVar(&c.config.EnableWorkerProcess)
app.Flag(
@@ -183,6 +185,9 @@ func (c *Collector) Close() error {
c.workerWG.Wait()
}
// The session belongs to the caller of Build.
c.miSession = nil
return nil
}
@@ -329,31 +334,50 @@ func (c *Collector) Build(logger *slog.Logger, miSession *mi.Session) error {
nil,
)
c.miSession = nil
if c.config.EnableWorkerProcess {
if miSession == nil {
return errors.New("miSession is nil")
}
miQuery, err := mi.NewQuery("SELECT AppPoolName, ProcessId FROM WorkerProcess")
if err != nil {
return fmt.Errorf("failed to create WMI query: %w", err)
}
c.workerProcessMIQueryQuery = miQuery
c.miSession = miSession
var workerProcesses []WorkerProcess
if err = c.miSession.Query(&workerProcesses, mi.NamespaceRootWebAdministration, c.workerProcessMIQueryQuery, 0); err != nil {
c.config.EnableWorkerProcess = false
return fmt.Errorf("WMI query for collector.process.iis failed: %w", err)
if err := c.buildWorkerProcessWMI(miSession); err != nil {
return err
}
}
return nil
}
// buildWorkerProcessWMI enables the root\WebAdministration fallback for application pool names
// if the IIS WMI provider is installed. Without it, only the w3wp command line is used.
func (c *Collector) buildWorkerProcessWMI(miSession *mi.Session) error {
if miSession == nil {
c.logger.LogAttrs(context.Background(), slog.LevelDebug,
"No MI session, reading IIS application pool names from the w3wp command line only",
)
return nil
}
miQuery, err := mi.NewQuery("SELECT AppPoolName, ProcessId FROM WorkerProcess")
if err != nil {
return fmt.Errorf("failed to create WMI query: %w", err)
}
var workerProcesses []WorkerProcess
if err = miSession.Query(&workerProcesses, mi.NamespaceRootWebAdministration, miQuery, 0); err != nil {
c.logger.LogAttrs(context.Background(), slog.LevelDebug,
`root\WebAdministration isn't available, reading IIS application pool names from the w3wp command line only`,
slog.Any("err", err),
)
return nil
}
c.workerProcessMIQueryQuery = miQuery
c.miSession = miSession
return nil
}
func (c *Collector) Collect(ch chan<- prometheus.Metric, maxScrapeDuration time.Duration) error {
return c.collect(ch, maxScrapeDuration)
}
@@ -0,0 +1,351 @@
// 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_test
import (
"context"
"errors"
"net/http"
"os"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
"testing"
"time"
"unsafe"
"github.com/prometheus-community/windows_exporter/internal/collector/process"
"github.com/prometheus-community/windows_exporter/internal/mi"
"github.com/prometheus-community/windows_exporter/internal/types"
"github.com/prometheus-community/windows_exporter/internal/utils/testutils"
"github.com/stretchr/testify/require"
"golang.org/x/sys/windows"
)
// appPoolIdentityPrefix is the domain of ApplicationPoolIdentity virtual accounts,
// whose names are the application pool names.
const appPoolIdentityPrefix = `IIS APPPOOL\`
// The application pool read from each w3wp command line must match
// WorkerProcess.AppPoolName and the ApplicationPoolIdentity of the process.
func TestWorkerProcessAppPoolIIS(t *testing.T) {
pids := requireWorkerProcesses(t)
appPools := make(map[uint32]string, len(pids))
for _, pid := range pids {
appPool, err := process.WorkerProcessAppPool(pid)
if errors.Is(err, windows.ERROR_INVALID_PARAMETER) {
// The worker process exited, for example after its idle timeout.
continue
}
if errors.Is(err, windows.ERROR_ACCESS_DENIED) && !iisRequired() {
t.Skipf("worker process %d: %v; run the test elevated", pid, err)
}
require.NoError(t, err, "worker process %d", pid)
require.NotEmpty(t, appPool)
appPools[pid] = appPool
if owner := processOwner(t, pid); strings.HasPrefix(owner, appPoolIdentityPrefix) {
require.Equal(t, strings.TrimPrefix(owner, appPoolIdentityPrefix), appPool, "worker process %d", pid)
}
}
workerProcesses, err := queryWorkerProcesses(t)
if err != nil {
t.Logf(`root\WebAdministration isn't available, skipping the WMI comparison: %v`, err)
return
}
for _, wp := range workerProcesses {
if appPool, ok := appPools[uint32(wp.ProcessId)]; ok {
require.Equal(t, wp.AppPoolName, appPool, "worker process %d", wp.ProcessId)
}
}
}
// The collector must publish the application pool in the process label.
func TestCollectorIISAppPool(t *testing.T) {
requireWorkerProcesses(t)
families := testutils.TestCollector(t, process.New, &process.Config{
ProcessInclude: regexp.MustCompile(`^(?:w3wp)$`),
ProcessExclude: types.RegExpEmpty,
EnableWorkerProcess: true,
CounterVersion: 1,
})
require.Contains(t, families, "windows_process_info")
var labeled int
for _, metric := range families["windows_process_info"].GetMetric() {
labels := make(map[string]string)
for _, label := range metric.GetLabel() {
labels[label.GetName()] = label.GetValue()
}
owner := labels["owner"]
if !strings.HasPrefix(owner, appPoolIdentityPrefix) {
continue
}
require.Equal(t, "w3wp_"+strings.TrimPrefix(owner, appPoolIdentityPrefix), labels["process"], "process_id %s", labels["process_id"])
labeled++
}
if iisRequired() {
require.Positive(t, labeled, "no worker process runs as an ApplicationPoolIdentity")
}
}
// A suspended copy of the test binary named w3wp.exe, started with a WAS
// command line, must get the pool in its process label without IIS.
func TestCollectorWorkerProcessCommandLine(t *testing.T) {
executable, err := os.Executable()
require.NoError(t, err)
content, err := os.ReadFile(executable)
require.NoError(t, err)
workerProcess := filepath.Join(t.TempDir(), "w3wp.exe")
//nolint:gosec // The destination is a fixed filename in a test-owned temporary directory.
require.NoError(t, os.WriteFile(workerProcess, content, 0o600))
const appPool = "Test Pool Ωμέγα 🚀"
pid := startSuspended(t, workerProcess, `"`+workerProcess+`" -ap "`+appPool+`" -v "v4.0" -w "" -m 0`)
families := testutils.TestCollector(t, process.New, &process.Config{
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")
}
for _, metric := range families["windows_process_info"].GetMetric() {
labels := make(map[string]string)
for _, label := range metric.GetLabel() {
labels[label.GetName()] = label.GetValue()
}
if labels["process_id"] == strconv.FormatUint(uint64(pid), 10) {
require.Equal(t, "w3wp_"+appPool, labels["process"])
return
}
}
t.Fatalf("windows_process_info has no series for process %d", pid)
}
// startSuspended starts a process that never runs and returns its ID.
func startSuspended(t *testing.T, path, cmdLine string) uint32 {
t.Helper()
appName, err := windows.UTF16PtrFromString(path)
require.NoError(t, err)
cmdLineUTF16, err := windows.UTF16FromString(cmdLine)
require.NoError(t, err)
startupInfo := windows.StartupInfo{Cb: uint32(unsafe.Sizeof(windows.StartupInfo{}))}
var processInfo windows.ProcessInformation
require.NoError(t, windows.CreateProcess(appName, &cmdLineUTF16[0], nil, nil, false,
windows.CREATE_SUSPENDED|windows.CREATE_NO_WINDOW, nil, nil, &startupInfo, &processInfo))
t.Cleanup(func() {
require.NoError(t, windows.TerminateProcess(processInfo.Process, 1))
_, _ = windows.WaitForSingleObject(processInfo.Process, windows.INFINITE)
require.NoError(t, windows.CloseHandle(processInfo.Thread))
require.NoError(t, windows.CloseHandle(processInfo.Process))
})
return processInfo.ProcessId
}
// iisRequired reports whether CI provisioned IIS, so a missing worker process is a failure.
func iisRequired() bool {
return slices.Contains(strings.Split(os.Getenv("WINDOWS_EXPORTER_TEST_COLLECTORS"), ","), "iis")
}
// requireWorkerProcesses returns the IIS worker processes. If W3SVC runs
// without one, it requests the local default site to start one.
func requireWorkerProcesses(t *testing.T) []uint32 {
t.Helper()
if pids := workerProcessIDs(t); len(pids) > 0 {
return pids
}
if !serviceRunning(t, "W3SVC") {
if iisRequired() {
t.Fatal("W3SVC isn't running")
}
t.Skip("IIS isn't running")
}
ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://127.0.0.1/", nil)
require.NoError(t, err)
resp, err := http.DefaultClient.Do(req)
if err == nil {
_ = resp.Body.Close()
} else {
t.Logf("request to start a worker process failed: %v", err)
}
pids := workerProcessIDs(t)
if len(pids) == 0 {
if iisRequired() {
t.Fatal("no IIS worker process is running")
}
t.Skip("no IIS worker process is running")
}
return pids
}
func workerProcessIDs(t *testing.T) []uint32 {
t.Helper()
snapshot, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
require.NoError(t, err)
defer func() { _ = windows.CloseHandle(snapshot) }()
var (
pids []uint32
entry = windows.ProcessEntry32{Size: uint32(unsafe.Sizeof(windows.ProcessEntry32{}))}
)
for err = windows.Process32First(snapshot, &entry); err == nil; err = windows.Process32Next(snapshot, &entry) {
if strings.EqualFold(windows.UTF16ToString(entry.ExeFile[:]), "w3wp.exe") {
pids = append(pids, entry.ProcessID)
}
}
require.ErrorIs(t, err, windows.ERROR_NO_MORE_FILES)
return pids
}
func serviceRunning(t *testing.T, name string) bool {
t.Helper()
scm, err := windows.OpenSCManager(nil, nil, windows.SC_MANAGER_CONNECT)
require.NoError(t, err)
defer func() { _ = windows.CloseServiceHandle(scm) }()
serviceName, err := windows.UTF16PtrFromString(name)
require.NoError(t, err)
h, err := windows.OpenService(scm, serviceName, windows.SERVICE_QUERY_STATUS)
if errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST) {
return false
}
require.NoError(t, err)
defer func() { _ = windows.CloseServiceHandle(h) }()
var status windows.SERVICE_STATUS
require.NoError(t, windows.QueryServiceStatus(h, &status))
return status.CurrentState == windows.SERVICE_RUNNING
}
// processOwner returns the DOMAIN\user owner of pid, or "" if it can't be read.
func processOwner(t *testing.T, pid uint32) string {
t.Helper()
hProcess, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid)
if err != nil {
return ""
}
defer func() { _ = windows.CloseHandle(hProcess) }()
var token windows.Token
if err := windows.OpenProcessToken(hProcess, windows.TOKEN_QUERY, &token); err != nil {
return ""
}
defer func() { _ = token.Close() }()
user, err := token.GetTokenUser()
require.NoError(t, err)
account, domain, _, err := user.User.Sid.LookupAccount("")
if err != nil {
t.Logf("worker process %d: LookupAccountSid: %v", pid, err)
return ""
}
return domain + `\` + account
}
func queryWorkerProcesses(t *testing.T) ([]process.WorkerProcess, error) {
t.Helper()
miApp, err := mi.ApplicationInitialize()
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, miApp.Close()) })
miSession, err := miApp.NewSession(nil)
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, miSession.Close()) })
query, err := mi.NewQuery("SELECT AppPoolName, ProcessId FROM WorkerProcess")
require.NoError(t, err)
var workerProcesses []process.WorkerProcess
if err := miSession.Query(&workerProcesses, mi.NamespaceRootWebAdministration, query, 0); err != nil {
return nil, err
}
for _, wp := range workerProcesses {
t.Logf("WorkerProcess %s: %q", strconv.FormatUint(wp.ProcessId, 10), wp.AppPoolName)
}
return workerProcesses, nil
}
+103 -23
View File
@@ -19,6 +19,7 @@ package process
import (
"context"
"errors"
"fmt"
"log/slog"
"runtime/debug"
@@ -29,14 +30,15 @@ import (
"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
workerProcesses []WorkerProcess
}
func (c *Collector) collect(ch chan<- prometheus.Metric, maxScrapeDuration time.Duration) error {
@@ -45,16 +47,9 @@ func (c *Collector) collect(ch chan<- prometheus.Metric, maxScrapeDuration time.
return fmt.Errorf("failed to collect metrics: %w", err)
}
err = nil
requests := make([]processWorkerRequest, 0, len(c.perfDataObject))
var workerProcesses []WorkerProcess
if c.config.EnableWorkerProcess {
if err = c.miSession.Query(&workerProcesses, mi.NamespaceRootWebAdministration, c.workerProcessMIQueryQuery, maxScrapeDuration); err != nil {
err = fmt.Errorf("WMI query for collector.process.iis failed: %w", err)
}
}
wg := &sync.WaitGroup{}
var workerProcessIDs []uint32
for _, process := range c.perfDataObject {
// Duplicate processes are suffixed #, and an index number. Remove those.
@@ -77,15 +72,32 @@ func (c *Collector) collect(ch chan<- prometheus.Metric, maxScrapeDuration time.
continue
}
wg.Add(1)
if c.config.EnableWorkerProcess && isWorkerProcess(name) {
workerProcessIDs = append(workerProcessIDs, uint32(process.ProcessID))
}
c.workerCh <- processWorkerRequest{
requests = append(requests, processWorkerRequest{
ch: ch,
name: name,
performanceCounterValues: process,
workerProcesses: workerProcesses,
waitGroup: wg,
}
})
}
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()
@@ -93,6 +105,80 @@ func (c *Collector) collect(ch chan<- prometheus.Metric, maxScrapeDuration time.
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()
@@ -115,14 +201,8 @@ func (c *Collector) collectWorker(requests <-chan processWorkerRequest) {
pid := uint64(data.ProcessID)
parentPID := strconv.FormatUint(uint64(data.CreatingProcessID), 10)
if c.config.EnableWorkerProcess {
for _, wp := range req.workerProcesses {
if wp.ProcessId == pid {
name = strings.Join([]string{name, wp.AppPoolName}, "_")
break
}
}
if req.appPoolName != "" {
name = name + "_" + req.appPoolName
}
cmdLine, processOwner, processGroupID, err := c.getProcessInformation(uint32(pid))