// 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 ( "errors" "fmt" "log/slog" "regexp" "slices" "strconv" "strings" "time" "github.com/alecthomas/kingpin/v2" "github.com/prometheus-community/windows_exporter/internal/mi" "github.com/prometheus-community/windows_exporter/internal/types" "github.com/prometheus/client_golang/prometheus" "golang.org/x/sys/windows/registry" ) const Name = "iis" type Config struct { SiteInclude *regexp.Regexp `yaml:"site-include"` SiteExclude *regexp.Regexp `yaml:"site-exclude"` AppInclude *regexp.Regexp `yaml:"app-include"` AppExclude *regexp.Regexp `yaml:"app-exclude"` } //nolint:gochecknoglobals var ConfigDefaults = Config{ SiteInclude: types.RegExpAny, SiteExclude: types.RegExpEmpty, AppInclude: types.RegExpAny, AppExclude: types.RegExpEmpty, } type Collector struct { collectorWebService collectorHttpServiceRequestQueues collectorAppPoolWAS collectorW3SVCW3WP collectorWebServiceCache config Config iisVersion simpleVersion logger *slog.Logger info *prometheus.Desc } func New(config *Config) *Collector { if config == nil { config = &ConfigDefaults } if config.AppExclude == nil { config.AppExclude = ConfigDefaults.AppExclude } if config.AppInclude == nil { config.AppInclude = ConfigDefaults.AppInclude } if config.SiteExclude == nil { config.SiteExclude = ConfigDefaults.SiteExclude } if config.SiteInclude == nil { config.SiteInclude = ConfigDefaults.SiteInclude } c := &Collector{ config: *config, } return c } func NewWithFlags(app *kingpin.Application) *Collector { c := &Collector{ config: ConfigDefaults, } var appExclude, appInclude, siteExclude, siteInclude string app.Flag( "collector.iis.app-exclude", "Regexp of apps to exclude. App name must both match include and not match exclude to be included.", ).Default("").StringVar(&appExclude) app.Flag( "collector.iis.app-include", "Regexp of apps to include. App name must both match include and not match exclude to be included.", ).Default(".+").StringVar(&appInclude) app.Flag( "collector.iis.site-exclude", "Regexp of sites to exclude. Site name must both match include and not match exclude to be included.", ).Default("").StringVar(&siteExclude) app.Flag( "collector.iis.site-include", "Regexp of sites to include. Site name must both match include and not match exclude to be included.", ).Default(".+").StringVar(&siteInclude) app.Action(func(*kingpin.ParseContext) error { var err error c.config.AppExclude, err = regexp.Compile(fmt.Sprintf("^(?:%s)$", appExclude)) if err != nil { return fmt.Errorf("collector.iis.app-exclude: %w", err) } c.config.AppInclude, err = regexp.Compile(fmt.Sprintf("^(?:%s)$", appInclude)) if err != nil { return fmt.Errorf("collector.iis.app-include: %w", err) } c.config.SiteExclude, err = regexp.Compile(fmt.Sprintf("^(?:%s)$", siteExclude)) if err != nil { return fmt.Errorf("collector.iis.site-exclude: %w", err) } c.config.SiteInclude, err = regexp.Compile(fmt.Sprintf("^(?:%s)$", siteInclude)) if err != nil { return fmt.Errorf("collector.iis.site-include: %w", err) } return nil }) return c } func (c *Collector) GetName() string { return Name } func (c *Collector) Close() error { c.perfDataCollectorWebService.Close() c.perfDataCollectorHttpServiceRequestQueues.Close() c.perfDataCollectorAppPoolWAS.Close() if c.w3SVCW3WPPerfDataCollector != nil { c.w3SVCW3WPPerfDataCollector.Close() } c.w3SVCW3WPPerfDataCollectorV8.Close() if c.serviceCachePerfDataCollector != nil { c.serviceCachePerfDataCollector.Close() } return nil } func (c *Collector) Build(logger *slog.Logger, _ *mi.Session) error { c.logger = logger.With(slog.String("collector", Name)) c.iisVersion = c.getIISVersion() c.info = prometheus.NewDesc( prometheus.BuildFQName(types.Namespace, Name, "info"), "ISS information", []string{}, prometheus.Labels{"version": fmt.Sprintf("%d.%d", c.iisVersion.major, c.iisVersion.minor)}, ) errs := make([]error, 0) if err := c.buildWebService(); err != nil { errs = append(errs, fmt.Errorf("failed to build Web Service collector: %w", err)) } if err := c.buildHttpServiceRequestQueues(); err != nil { errs = append(errs, fmt.Errorf("failed to build Http Service collector: %w", err)) } if err := c.buildAppPoolWAS(); err != nil { errs = append(errs, fmt.Errorf("failed to build APP_POOL_WAS collector: %w", err)) } if err := c.buildW3SVCW3WP(); err != nil { errs = append(errs, fmt.Errorf("failed to build W3SVC_W3WP collector: %w", err)) } if err := c.buildWebServiceCache(); err != nil { errs = append(errs, fmt.Errorf("failed to build Web Service Cache collector: %w", err)) } return errors.Join(errs...) } type simpleVersion struct { major uint64 minor uint64 } func (c *Collector) getIISVersion() simpleVersion { k, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\InetStp\`, registry.QUERY_VALUE) if err != nil { c.logger.Warn("couldn't open registry to determine IIS version", slog.Any("err", err), ) return simpleVersion{} } defer func() { err = k.Close() if err != nil { c.logger.Warn("Failed to close registry key", slog.Any("err", err), ) } }() major, _, err := k.GetIntegerValue("MajorVersion") if err != nil { c.logger.Warn("Couldn't open registry to determine IIS version", slog.Any("err", err), ) return simpleVersion{} } minor, _, err := k.GetIntegerValue("MinorVersion") if err != nil { c.logger.Warn("Couldn't open registry to determine IIS version", slog.Any("err", err), ) return simpleVersion{} } c.logger.Debug(fmt.Sprintf("Detected IIS %d.%d\n", major, minor)) return simpleVersion{ major: major, minor: minor, } } // Collect sends the metric values for each metric // to the provided prometheus Metric channel. func (c *Collector) Collect(ch chan<- prometheus.Metric, _ time.Duration) error { ch <- prometheus.MustNewConstMetric( c.info, prometheus.GaugeValue, 1, ) errs := make([]error, 0) if err := c.collectWebService(ch); err != nil { errs = append(errs, fmt.Errorf("failed to collect Web Service metrics: %w", err)) } if err := c.collectHttpServiceRequestQueues(ch); err != nil { errs = append(errs, fmt.Errorf("failed to collect Http Service Request Queues metrics: %w", err)) } if err := c.collectAppPoolWAS(ch); err != nil { errs = append(errs, fmt.Errorf("failed to collect APP_POOL_WAS metrics: %w", err)) } if err := c.collectW3SVCW3WP(ch); err != nil { errs = append(errs, fmt.Errorf("failed to collect W3SVC_W3WP metrics: %w", err)) } if err := c.collectWebServiceCache(ch); err != nil { errs = append(errs, fmt.Errorf("failed to collect Web Service Cache metrics: %w", err)) } return errors.Join(errs...) } type collectorName interface { GetName() string } // deduplicateIISNames deduplicate IIS site names from various IIS perflib objects. // // IIS perflib may report duplicate entries for the same site/app-pool when // instances are recycled; the duplicate carries a numeric suffix separated by // "#" (e.g. "Site_B#2"). In that case "Site_B" is discarded and "Site_B#2" // is kept. Callers must use iisCounterBaseName to present it as "Site_B". // // Application Pool names may legitimately contain a "#" character, so only a // trailing "#" 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 } // Sort by base name for a deterministic output order. slices.SortFunc(counterValues, func(a, b T) int { return strings.Compare(iisCounterBaseName(a.GetName()), iisCounterBaseName(b.GetName())) }) seen := make(map[string]dedupeEntry, len(counterValues)) result := make([]T, 0, len(counterValues)) for _, counterValue := range counterValues { 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 e, ok := seen[baseName]; ok { // Keep whichever entry carries the higher numeric suffix. if suffix > e.numericSuffix { result[e.resultIdx] = counterValue seen[baseName] = dedupeEntry{e.resultIdx, suffix} } } else { seen[baseName] = dedupeEntry{len(result), suffix} result = append(result, counterValue) } } return result } // iisCounterBaseName strips a trailing IIS perflib numeric suffix ("#") // 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] }