From 4ca7dc8957ad5668e641b7184a131867ec00514f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan-Otto=20Kr=C3=B6pke?= Date: Sat, 10 Oct 2026 15:44:24 +0200 Subject: [PATCH] process: read the IIS application pool from the w3wp command line (#2638) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Opus 5.5 Signed-off-by: Jan-Otto Kröpke --- docs/collector.process.md | 19 +- internal/collector/process/AGENTS.md | 20 + internal/collector/process/apppool.go | 276 ++++++++++++ internal/collector/process/apppool_test.go | 416 ++++++++++++++++++ internal/collector/process/export_test.go | 24 + internal/collector/process/process.go | 62 ++- .../collector/process/process_iis_test.go | 351 +++++++++++++++ internal/collector/process/process_worker.go | 126 +++++- 8 files changed, 1248 insertions(+), 46 deletions(-) create mode 100644 internal/collector/process/AGENTS.md create mode 100644 internal/collector/process/apppool.go create mode 100644 internal/collector/process/apppool_test.go create mode 100644 internal/collector/process/export_test.go create mode 100644 internal/collector/process/process_iis_test.go diff --git a/docs/collector.process.md b/docs/collector.process.md index 45f8d85f..305b102f 100644 --- a/docs/collector.process.md +++ b/docs/collector.process.md @@ -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 diff --git a/internal/collector/process/AGENTS.md b/internal/collector/process/AGENTS.md new file mode 100644 index 00000000..da739611 --- /dev/null +++ b/internal/collector/process/AGENTS.md @@ -0,0 +1,20 @@ +# process collector + +IIS application pool names (`--collector.process.iis`) come from two sources. + +- Primary: the `-ap ""` 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\` 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. diff --git a/internal/collector/process/apppool.go b/internal/collector/process/apppool.go new file mode 100644 index 00000000..8e1f73c8 --- /dev/null +++ b/internal/collector/process/apppool.go @@ -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 "" ...`. 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 +} diff --git a/internal/collector/process/apppool_test.go b/internal/collector/process/apppool_test.go new file mode 100644 index 00000000..0475ace9 --- /dev/null +++ b/internal/collector/process/apppool_test.go @@ -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) +} diff --git a/internal/collector/process/export_test.go b/internal/collector/process/export_test.go new file mode 100644 index 00000000..1b758c52 --- /dev/null +++ b/internal/collector/process/export_test.go @@ -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 diff --git a/internal/collector/process/process.go b/internal/collector/process/process.go index 73f3f009..7e208d5e 100644 --- a/internal/collector/process/process.go +++ b/internal/collector/process/process.go @@ -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) } diff --git a/internal/collector/process/process_iis_test.go b/internal/collector/process/process_iis_test.go new file mode 100644 index 00000000..b72b4aa5 --- /dev/null +++ b/internal/collector/process/process_iis_test.go @@ -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 +} diff --git a/internal/collector/process/process_worker.go b/internal/collector/process/process_worker.go index 2d0ac6cb..1c46fa52 100644 --- a/internal/collector/process/process_worker.go +++ b/internal/collector/process/process_worker.go @@ -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))