mirror of
https://github.com/prometheus-community/windows_exporter.git
synced 2026-10-20 19:49:03 +02:00
iis: fix collector panic on application pool names containing '#' (#2473)
Co-authored-by: jkroepke <1560587+jkroepke@users.noreply.github.com> Co-authored-by: Jan-Otto Kröpke <mail@jkroepke.de> 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:
co-authored by
jkroepke
Jan-Otto Kröpke
Claude Opus 5.5
parent
413f0b5bbd
commit
485fcb8d24
@@ -23,6 +23,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"regexp"
|
"regexp"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -290,30 +291,77 @@ type collectorName interface {
|
|||||||
|
|
||||||
// deduplicateIISNames deduplicate IIS site names from various IIS perflib objects.
|
// deduplicateIISNames deduplicate IIS site names from various IIS perflib objects.
|
||||||
//
|
//
|
||||||
// E.G. Given the following list of site names, "Site_B" would be
|
// IIS perflib may report duplicate entries for the same site/app-pool when
|
||||||
// discarded, and "Site_B#2" would be kept and presented as "Site_B" in the
|
// instances are recycled; the duplicate carries a numeric suffix separated by
|
||||||
// Collector metrics.
|
// "#" (e.g. "Site_B#2"). In that case "Site_B" is discarded and "Site_B#2"
|
||||||
// [ "Site_A", "Site_B", "Site_C", "Site_B#2" ].
|
// is kept. Callers must use iisCounterBaseName to present it as "Site_B".
|
||||||
func deduplicateIISNames[T collectorName](counterValues []T) {
|
//
|
||||||
indexes := make(map[string]int)
|
// Application Pool names may legitimately contain a "#" character, so only a
|
||||||
|
// trailing "#<digits>" pattern is treated as an IIS counter suffix.
|
||||||
|
//
|
||||||
|
// Example input: [ "Site_A", "Site_B", "Site_C", "Site_B#2" ]
|
||||||
|
// Example output: [ "Site_A", "Site_B#2", "Site_C" ].
|
||||||
|
func deduplicateIISNames[T collectorName](counterValues []T) []T {
|
||||||
|
type dedupeEntry struct {
|
||||||
|
resultIdx int // index into result slice
|
||||||
|
numericSuffix int // -1 for base (no suffix), ≥0 for "#N" variants
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure IIS entry with the highest suffix occurs last
|
// Sort by base name for a deterministic output order.
|
||||||
slices.SortFunc(counterValues, func(a, b T) int {
|
slices.SortFunc(counterValues, func(a, b T) int {
|
||||||
return strings.Compare(a.GetName(), b.GetName())
|
return strings.Compare(iisCounterBaseName(a.GetName()), iisCounterBaseName(b.GetName()))
|
||||||
})
|
})
|
||||||
|
|
||||||
// Use map to deduplicate IIS entries
|
seen := make(map[string]dedupeEntry, len(counterValues))
|
||||||
for index, counterValue := range counterValues {
|
result := make([]T, 0, len(counterValues))
|
||||||
name, _, _ := strings.Cut(counterValue.GetName(), "#")
|
|
||||||
if name == counterValue.GetName() {
|
for _, counterValue := range counterValues {
|
||||||
continue
|
name := counterValue.GetName()
|
||||||
|
baseName := iisCounterBaseName(name)
|
||||||
|
|
||||||
|
// Determine the numeric suffix for this entry (-1 means "no suffix").
|
||||||
|
suffix := -1
|
||||||
|
|
||||||
|
if baseName != name {
|
||||||
|
// iisCounterBaseName guarantees the remainder after '#' is all digits.
|
||||||
|
if n, err := strconv.Atoi(name[len(baseName)+1:]); err == nil {
|
||||||
|
suffix = n
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if originalIndex, ok := indexes[name]; !ok {
|
if e, ok := seen[baseName]; ok {
|
||||||
counterValues[originalIndex] = counterValue
|
// Keep whichever entry carries the higher numeric suffix.
|
||||||
counterValues = slices.Delete(counterValues, index, 1)
|
if suffix > e.numericSuffix {
|
||||||
|
result[e.resultIdx] = counterValue
|
||||||
|
seen[baseName] = dedupeEntry{e.resultIdx, suffix}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
indexes[name] = index
|
seen[baseName] = dedupeEntry{len(result), suffix}
|
||||||
|
result = append(result, counterValue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// iisCounterBaseName strips a trailing IIS perflib numeric suffix ("#<digits>")
|
||||||
|
// from name and returns the base name. If no such suffix is present, name is
|
||||||
|
// returned unchanged.
|
||||||
|
//
|
||||||
|
// Only a trailing "#" followed exclusively by ASCII digits is treated as a
|
||||||
|
// counter suffix, so "#" characters that are part of a legitimate application
|
||||||
|
// name (e.g. "App#Pool") are left intact.
|
||||||
|
func iisCounterBaseName(name string) string {
|
||||||
|
idx := strings.LastIndex(name, "#")
|
||||||
|
if idx < 0 || idx == len(name)-1 {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, r := range name[idx+1:] {
|
||||||
|
if r < '0' || r > '9' {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return name[:idx]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,9 +173,12 @@ func (c *Collector) collectAppPoolWAS(ch chan<- prometheus.Metric) error {
|
|||||||
return fmt.Errorf("failed to collect APP_POOL_WAS metrics: %w", err)
|
return fmt.Errorf("failed to collect APP_POOL_WAS metrics: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
deduplicateIISNames(c.perfDataObjectAppPoolWAS)
|
c.perfDataObjectAppPoolWAS = deduplicateIISNames(c.perfDataObjectAppPoolWAS)
|
||||||
|
|
||||||
for _, data := range c.perfDataObjectAppPoolWAS {
|
for _, data := range c.perfDataObjectAppPoolWAS {
|
||||||
|
// Present recycled instances ("Site_B#2") under their base name ("Site_B").
|
||||||
|
data.Name = iisCounterBaseName(data.Name)
|
||||||
|
|
||||||
if c.config.AppExclude.MatchString(data.Name) || !c.config.AppInclude.MatchString(data.Name) {
|
if c.config.AppExclude.MatchString(data.Name) || !c.config.AppInclude.MatchString(data.Name) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
// 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 iis
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testEntry is a minimal collectorName implementation used in tests.
|
||||||
|
type testEntry struct {
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e testEntry) GetName() string { return e.Name }
|
||||||
|
|
||||||
|
func names(entries []testEntry) []string {
|
||||||
|
out := make([]string, len(entries))
|
||||||
|
for i, e := range entries {
|
||||||
|
out[i] = e.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func mkEntries(ss ...string) []testEntry {
|
||||||
|
out := make([]testEntry, len(ss))
|
||||||
|
for i, s := range ss {
|
||||||
|
out[i] = testEntry{Name: s}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIISCounterBaseName(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
// Standard IIS counter suffixes – numeric only.
|
||||||
|
{"Site_B#2", "Site_B"},
|
||||||
|
{"Site_B#10", "Site_B"},
|
||||||
|
// Pool name legitimately contains '#' – not a numeric suffix.
|
||||||
|
{"App#Pool", "App#Pool"},
|
||||||
|
{"Pool#Name#2", "Pool#Name"},
|
||||||
|
// No '#' at all.
|
||||||
|
{"DefaultAppPool", "DefaultAppPool"},
|
||||||
|
// Edge-cases.
|
||||||
|
{"#2", ""},
|
||||||
|
{"Site#", "Site#"}, // empty suffix → not treated as counter
|
||||||
|
{"Site##2", "Site#"}, // double '#', last segment is numeric
|
||||||
|
{"Site#+2", "Site#+2"}, // sign is not a counter suffix
|
||||||
|
{"Site#-1", "Site#-1"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.input, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := iisCounterBaseName(tt.input)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("iisCounterBaseName(%q) = %q, want %q", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeduplicateIISNames(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input []testEntry
|
||||||
|
// wantNames is the set of Name fields present in the result (order-independent).
|
||||||
|
wantNames []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no duplicates",
|
||||||
|
input: mkEntries("Site_A", "Site_B", "Site_C"),
|
||||||
|
wantNames: []string{"Site_A", "Site_B", "Site_C"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "one recycled instance",
|
||||||
|
input: mkEntries("Site_A", "Site_B", "Site_C", "Site_B#2"),
|
||||||
|
wantNames: []string{"Site_A", "Site_B#2", "Site_C"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple recycled suffixes – keep highest",
|
||||||
|
input: mkEntries("Site_B", "Site_B#3", "Site_B#2"),
|
||||||
|
wantNames: []string{"Site_B#3"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple recycled suffixes with high numbers – keep highest",
|
||||||
|
input: mkEntries("Site_B", "Site_B#9", "Site_B#10"),
|
||||||
|
wantNames: []string{"Site_B#10"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pool name with hash character (not a suffix)",
|
||||||
|
input: mkEntries("App#Pool", "Other Pool"),
|
||||||
|
wantNames: []string{"App#Pool", "Other Pool"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pool name with hash does not clash with recycled entry",
|
||||||
|
input: mkEntries("App#Pool", "App#Pool#2"),
|
||||||
|
wantNames: []string{"App#Pool#2"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "space in pool name",
|
||||||
|
input: mkEntries("My App Pool", "Default App Pool"),
|
||||||
|
wantNames: []string{"My App Pool", "Default App Pool"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty input",
|
||||||
|
input: mkEntries(),
|
||||||
|
wantNames: []string{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := deduplicateIISNames(tt.input)
|
||||||
|
gotNames := names(got)
|
||||||
|
|
||||||
|
if len(gotNames) != len(tt.wantNames) {
|
||||||
|
t.Fatalf("deduplicateIISNames(%v) returned %v, want %v",
|
||||||
|
names(tt.input), gotNames, tt.wantNames)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantSet := make(map[string]bool, len(tt.wantNames))
|
||||||
|
for _, n := range tt.wantNames {
|
||||||
|
wantSet[n] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, n := range gotNames {
|
||||||
|
if !wantSet[n] {
|
||||||
|
t.Errorf("unexpected name %q in result %v (want %v)", n, gotNames, tt.wantNames)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -93,9 +93,12 @@ func (c *Collector) collectHttpServiceRequestQueues(ch chan<- prometheus.Metric)
|
|||||||
return fmt.Errorf("failed to collect Http Service Request Queues metrics: %w", err)
|
return fmt.Errorf("failed to collect Http Service Request Queues metrics: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
deduplicateIISNames(c.perfDataObjectHttpServiceRequestQueues)
|
c.perfDataObjectHttpServiceRequestQueues = deduplicateIISNames(c.perfDataObjectHttpServiceRequestQueues)
|
||||||
|
|
||||||
for _, data := range c.perfDataObjectHttpServiceRequestQueues {
|
for _, data := range c.perfDataObjectHttpServiceRequestQueues {
|
||||||
|
// Present recycled instances ("Site_B#2") under their base name ("Site_B").
|
||||||
|
data.Name = iisCounterBaseName(data.Name)
|
||||||
|
|
||||||
if strings.HasPrefix(data.Name, "---") {
|
if strings.HasPrefix(data.Name, "---") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ package iis
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/prometheus-community/windows_exporter/internal/pdh"
|
"github.com/prometheus-community/windows_exporter/internal/pdh"
|
||||||
"github.com/prometheus-community/windows_exporter/internal/types"
|
"github.com/prometheus-community/windows_exporter/internal/types"
|
||||||
@@ -412,7 +411,7 @@ func (c *Collector) collectW3SVCW3WPv8(ch chan<- prometheus.Metric) error {
|
|||||||
return fmt.Errorf("failed to collect APP_POOL_WAS metrics: %w", err)
|
return fmt.Errorf("failed to collect APP_POOL_WAS metrics: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
deduplicateIISNames(c.perfDataObjectW3SVCW3WPV8)
|
c.perfDataObjectW3SVCW3WPV8 = deduplicateIISNames(c.perfDataObjectW3SVCW3WPV8)
|
||||||
|
|
||||||
for _, data := range c.perfDataObjectW3SVCW3WPV8 {
|
for _, data := range c.perfDataObjectW3SVCW3WPV8 {
|
||||||
if c.config.AppExclude.MatchString(data.Name) || !c.config.AppInclude.MatchString(data.Name) {
|
if c.config.AppExclude.MatchString(data.Name) || !c.config.AppInclude.MatchString(data.Name) {
|
||||||
@@ -422,17 +421,12 @@ func (c *Collector) collectW3SVCW3WPv8(ch chan<- prometheus.Metric) error {
|
|||||||
// Extract the apppool name from the format <PID>_<NAME>
|
// Extract the apppool name from the format <PID>_<NAME>
|
||||||
pid := workerProcessNameExtractor.ReplaceAllString(data.Name, "$1")
|
pid := workerProcessNameExtractor.ReplaceAllString(data.Name, "$1")
|
||||||
|
|
||||||
name := workerProcessNameExtractor.ReplaceAllString(data.Name, "$2")
|
name := iisCounterBaseName(workerProcessNameExtractor.ReplaceAllString(data.Name, "$2"))
|
||||||
if name == "" || c.config.AppExclude.MatchString(name) ||
|
if name == "" || c.config.AppExclude.MatchString(name) ||
|
||||||
!c.config.AppInclude.MatchString(name) {
|
!c.config.AppInclude.MatchString(name) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Duplicate instances are suffixed # with an index number. These should be ignored
|
|
||||||
if strings.Contains(name, "#") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
ch <- prometheus.MustNewConstMetric(
|
ch <- prometheus.MustNewConstMetric(
|
||||||
c.w3SVCW3WPRequestErrorsTotal,
|
c.w3SVCW3WPRequestErrorsTotal,
|
||||||
prometheus.CounterValue,
|
prometheus.CounterValue,
|
||||||
@@ -511,23 +505,18 @@ func (c *Collector) collectW3SVCW3WPv7(ch chan<- prometheus.Metric) error {
|
|||||||
return fmt.Errorf("failed to collect APP_POOL_WAS metrics: %w", err)
|
return fmt.Errorf("failed to collect APP_POOL_WAS metrics: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
deduplicateIISNames(c.perfDataObjectW3SVCW3WP)
|
c.perfDataObjectW3SVCW3WP = deduplicateIISNames(c.perfDataObjectW3SVCW3WP)
|
||||||
|
|
||||||
for _, data := range c.perfDataObjectW3SVCW3WP {
|
for _, data := range c.perfDataObjectW3SVCW3WP {
|
||||||
// Extract the apppool name from the format <PID>_<NAME>
|
// Extract the apppool name from the format <PID>_<NAME>
|
||||||
pid := workerProcessNameExtractor.ReplaceAllString(data.Name, "$1")
|
pid := workerProcessNameExtractor.ReplaceAllString(data.Name, "$1")
|
||||||
|
|
||||||
name := workerProcessNameExtractor.ReplaceAllString(data.Name, "$2")
|
name := iisCounterBaseName(workerProcessNameExtractor.ReplaceAllString(data.Name, "$2"))
|
||||||
if name == "" || c.config.AppExclude.MatchString(name) ||
|
if name == "" || c.config.AppExclude.MatchString(name) ||
|
||||||
!c.config.AppInclude.MatchString(name) {
|
!c.config.AppInclude.MatchString(name) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Duplicate instances are suffixed # with an index number. These should be ignored
|
|
||||||
if strings.Contains(name, "#") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
ch <- prometheus.MustNewConstMetric(
|
ch <- prometheus.MustNewConstMetric(
|
||||||
c.w3SVCW3WPThreads,
|
c.w3SVCW3WPThreads,
|
||||||
prometheus.GaugeValue,
|
prometheus.GaugeValue,
|
||||||
|
|||||||
@@ -249,9 +249,12 @@ func (c *Collector) collectWebService(ch chan<- prometheus.Metric) error {
|
|||||||
return fmt.Errorf("failed to collect Web Service metrics: %w", err)
|
return fmt.Errorf("failed to collect Web Service metrics: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
deduplicateIISNames(c.perfDataObjectWebService)
|
c.perfDataObjectWebService = deduplicateIISNames(c.perfDataObjectWebService)
|
||||||
|
|
||||||
for _, data := range c.perfDataObjectWebService {
|
for _, data := range c.perfDataObjectWebService {
|
||||||
|
// Present recycled instances ("Site_B#2") under their base name ("Site_B").
|
||||||
|
data.Name = iisCounterBaseName(data.Name)
|
||||||
|
|
||||||
if c.config.SiteExclude.MatchString(data.Name) || !c.config.SiteInclude.MatchString(data.Name) {
|
if c.config.SiteExclude.MatchString(data.Name) || !c.config.SiteInclude.MatchString(data.Name) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user