service: add delayed_start and trigger_start labels to windows_service_info (#2768)

Signed-off-by: Jan-Otto Kröpke <mail@jkroepke.de>
This commit is contained in:
Jan-Otto Kröpke
2026-10-16 21:30:11 +02:00
committed by GitHub
parent 4f3152d84d
commit 4f8fc2baef
8 changed files with 289 additions and 57 deletions
+13
View File
@@ -9,6 +9,19 @@
`RegNotifyChangeKeyValue` instead. Arm it with `REG_NOTIFY_THREAD_AGNOSTIC`:
without it the registration ends when the arming OS thread exits, and Go
goroutines are not bound to one.
- The delayed auto-start flag and the triggers are queried with
`QueryServiceConfig2` on the same handle and cached with the configuration.
The SCM ignores the delayed flag unless the start type is auto, so it is only
queried for auto-start services. `x/sys/windows` has no
`SERVICE_TRIGGER_INFO`; the mirror in `service.go` only reads `cTriggers`.
- The delayed flag is the `DelayedAutostart` value of the service key, so a
change updates the key's last write time. Triggers are stored in the
`TriggerInfo` subkey. Adding the first trigger creates it, which updates the
last write time of the service key (`TestCollectorPicksUpConfigChanges`
checks this). That a later trigger change made with `sc triggerinfo` also
does is assumed, not verified: the SCM is believed to rewrite the whole
`TriggerInfo` subkey. If it only changes deeper keys, the cache picks the
change up after at most `configCacheMaxAge`.
- The subtree notification fires every few seconds on a normal machine (W32Time,
Tcpip, VSS, driver `Enum` keys, Hyper-V). Treat it as a hint only and compare
the last write time of each service's own key, which changes only for its own
+5 -4
View File
@@ -26,7 +26,6 @@ import (
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
"golang.org/x/sys/windows/svc/mgr"
)
const (
@@ -54,7 +53,7 @@ var errConfigCacheClosed = errors.New("service configuration cache is closed")
// cachedServiceConfig is the result of queryServiceConfig.
type cachedServiceConfig struct {
config mgr.Config
config serviceConfig
available bool
// lastWrite is the last write time of the service's registry key, read
@@ -280,7 +279,7 @@ func (cache *serviceConfigCache) close() error {
// getCachedServiceConfig returns the configuration of a service from the cache,
// or queries and caches it on a miss.
func (c *Collector) getCachedServiceConfig(serviceName string) (mgr.Config, bool, error) {
func (c *Collector) getCachedServiceConfig(serviceName string) (serviceConfig, bool, error) {
entry, generation, ok := c.configCache.get(serviceName)
if ok {
return entry.config, entry.available, nil
@@ -296,7 +295,7 @@ func (c *Collector) getCachedServiceConfig(serviceName string) (mgr.Config, bool
config, available, err := c.queryServiceConfig(serviceName)
if err != nil {
return mgr.Config{}, false, err
return serviceConfig{}, false, err
}
switch {
@@ -308,6 +307,8 @@ func (c *Collector) getCachedServiceConfig(serviceName string) (mgr.Config, bool
case !available:
// An unavailable configuration, for example a display name from a missing
// MUI file, can become available without a change to the service key.
case !config.extendedAvailable:
// The delayed auto-start flag and triggers are queried again on the next scrape.
case time.Since(lastWrite) < configCacheMinKeyAge:
// Cached on a later scrape, once a second write in the same tick is ruled out.
default:
@@ -25,7 +25,6 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/sys/windows/registry"
"golang.org/x/sys/windows/svc/mgr"
)
// newTestConfigCache returns a cache that watches a temporary key below
@@ -68,7 +67,7 @@ func newTestConfigCache(t *testing.T) (*serviceConfigCache, registry.Key) {
_, generation, _ := cache.get(name)
cache.put(name, generation, cachedServiceConfig{
config: mgr.Config{DisplayName: name},
config: serviceConfig{DisplayName: name},
available: true,
lastWrite: lastWrite,
})
+130 -35
View File
@@ -95,6 +95,32 @@ type Collector struct {
metricCache metriccache.Cache[string, serviceValues]
}
// serviceConfig is the configuration of a service that the collector publishes.
// Of the embedded [mgr.Config], only BinaryPathName, DisplayName, StartType,
// ServiceStartName and DelayedAutoStart are set. DelayedAutoStart is only
// queried for auto-start services and false for the others.
type serviceConfig struct {
mgr.Config
// triggerStart is true if the service has triggers, which start or stop it
// on events such as a device arrival or a network change.
triggerStart bool
// extendedAvailable is true if DelayedAutoStart and triggerStart were queried
// successfully. Otherwise both are false and their labels stay empty.
extendedAvailable bool
}
// serviceTriggerInfo mirrors SERVICE_TRIGGER_INFO, which QueryServiceConfig2
// returns for SERVICE_CONFIG_TRIGGER_INFO. golang.org/x/sys/windows doesn't define it.
// Only cTriggers is read; the pointers point into the same buffer.
// https://learn.microsoft.com/en-us/windows/win32/api/winsvc/ns-winsvc-service_trigger_info
type serviceTriggerInfo struct {
triggers uint32 // cTriggers
trigger uintptr // pTriggers
_ uintptr // pReserved
}
// serviceValues holds the inputs of the metrics of a service besides its name.
type serviceValues struct {
state uint32
@@ -102,7 +128,10 @@ type serviceValues struct {
displayName string
runAs string
pathName string
processID uint32
// delayedStart and triggerStart are the label values "true" or "false".
delayedStart string
triggerStart string
processID uint32
// processStartTime is the creation time of the process in nanoseconds,
// or 0 if it is unknown and the process metric is not published.
processStartTime uint64
@@ -187,7 +216,9 @@ func (c *Collector) Build(logger *slog.Logger, _ *mi.Session) error {
c.serviceConfigPoolBytes = sync.Pool{
New: func() any {
return new([]byte)
buf := make([]byte, 1024)
return &buf
},
}
@@ -196,7 +227,7 @@ func (c *Collector) Build(logger *slog.Logger, _ *mi.Session) error {
c.info = prometheus.NewDesc(
prometheus.BuildFQName(types.Namespace, Name, "info"),
"A metric with a constant '1' value labeled with service information",
[]string{"service", "display_name", "run_as", "path_name"},
[]string{"service", "display_name", "run_as", "path_name", "delayed_start", "trigger_start"},
nil,
)
c.state = prometheus.NewDesc(
@@ -408,6 +439,13 @@ func (c *Collector) collectService(ch chan<- prometheus.Metric, scrape *metricca
processID: service.status.ProcessId,
}
// The labels stay empty if the service configuration or the delayed
// auto-start flag and triggers are not available.
if configAvailable && serviceConfig.extendedAvailable {
values.delayedStart = strconv.FormatBool(serviceConfig.DelayedAutoStart)
values.triggerStart = strconv.FormatBool(serviceConfig.triggerStart)
}
if values.processID != 0 {
values.processStartTime = c.processStartTime(serviceName, values.processID)
}
@@ -435,6 +473,8 @@ func (c *Collector) serviceMetrics(serviceName string, values serviceValues) []p
values.displayName,
values.runAs,
values.pathName,
values.delayedStart,
values.triggerStart,
))
var (
@@ -588,16 +628,16 @@ func (c *Collector) queryAllServices() ([]serviceEntry, error) {
// queryServiceConfig opens the service and queries its configuration.
// configAvailable is false if the service has no readable configuration.
func (c *Collector) queryServiceConfig(serviceName string) (mgr.Config, bool, error) {
func (c *Collector) queryServiceConfig(serviceName string) (serviceConfig, bool, error) {
serviceNamePtr, err := windows.UTF16PtrFromString(serviceName)
if err != nil {
return mgr.Config{}, false, fmt.Errorf("invalid service name: %w", err)
return serviceConfig{}, false, fmt.Errorf("invalid service name: %w", err)
}
// Open connection for service handler.
serviceHandle, err := windows.OpenService(c.serviceManagerHandle.Handle, serviceNamePtr, windows.SERVICE_QUERY_CONFIG)
if err != nil {
return mgr.Config{}, false, fmt.Errorf("failed to open service: %w", err)
return serviceConfig{}, false, fmt.Errorf("failed to open service: %w", err)
}
// Create handle for each service.
@@ -612,10 +652,10 @@ func (c *Collector) queryServiceConfig(serviceName string) (mgr.Config, bool, er
}(serviceManager)
// Get Service Configuration.
serviceConfig, err := c.getServiceConfig(serviceManager)
config, err := c.getServiceConfig(serviceManager)
if err != nil {
if !errors.Is(err, windows.ERROR_FILE_NOT_FOUND) && !errors.Is(err, windows.ERROR_MUI_FILE_NOT_FOUND) {
return mgr.Config{}, false, fmt.Errorf("failed to get service configuration: %w", err)
return serviceConfig{}, false, fmt.Errorf("failed to get service configuration: %w", err)
}
c.logger.Log(context.Background(), slog.LevelDebug, "failed collecting service config",
@@ -623,10 +663,10 @@ func (c *Collector) queryServiceConfig(serviceName string) (mgr.Config, bool, er
slog.String("service", serviceName),
)
return mgr.Config{}, false, nil
return serviceConfig{}, false, nil
}
return serviceConfig, true, nil
return config, true, nil
}
func (c *Collector) getProcessStartTime(pid uint32) (uint64, error) {
@@ -657,45 +697,100 @@ func (c *Collector) getProcessStartTime(pid uint32) (uint64, error) {
return uint64(creation.Nanoseconds()), nil
}
// getServiceConfig is an optimized variant of [mgr.Service] that only
// getServiceConfig is an optimized variant of [mgr.Service.Config] that only
// retrieves the necessary information.
func (c *Collector) getServiceConfig(service *mgr.Service) (mgr.Config, error) {
var serviceConfig *windows.QUERY_SERVICE_CONFIG
func (c *Collector) getServiceConfig(service *mgr.Service) (serviceConfig, error) {
buf := c.serviceConfigPoolBytes.Get().(*[]byte) //nolint:forcetypeassert // The pool only holds *[]byte.
defer c.serviceConfigPoolBytes.Put(buf)
bytesNeeded := uint32(1024)
buf, ok := c.serviceConfigPoolBytes.Get().(*[]byte)
if !ok || len(*buf) == 0 {
*buf = make([]byte, bytesNeeded)
} else {
bytesNeeded = uint32(cap(*buf))
err := queryServiceConfigBuffer(buf, func(data *byte, size uint32, bytesNeeded *uint32) error {
return windows.QueryServiceConfig(service.Handle, (*windows.QUERY_SERVICE_CONFIG)(unsafe.Pointer(data)), size, bytesNeeded)
})
if err != nil {
return serviceConfig{}, err
}
for {
serviceConfig = (*windows.QUERY_SERVICE_CONFIG)(unsafe.Pointer(&(*buf)[0]))
queryServiceConfig := (*windows.QUERY_SERVICE_CONFIG)(unsafe.Pointer(&(*buf)[0]))
err := windows.QueryServiceConfig(service.Handle, serviceConfig, bytesNeeded, &bytesNeeded)
config := serviceConfig{
BinaryPathName: windows.UTF16PtrToString(queryServiceConfig.BinaryPathName),
DisplayName: windows.UTF16PtrToString(queryServiceConfig.DisplayName),
StartType: queryServiceConfig.StartType,
ServiceStartName: windows.UTF16PtrToString(queryServiceConfig.ServiceStartName),
}
// A failure only leaves the delayed_start and trigger_start labels empty.
// It must not hide the rest of the configuration.
if err := queryExtendedServiceConfig(service.Handle, buf, &config); err != nil {
c.logger.Log(context.Background(), slog.LevelDebug, "failed to query the delayed auto-start flag and triggers of the service",
slog.Any("err", err),
slog.String("service", service.Name),
)
config.DelayedAutoStart = false
config.triggerStart = false
return config, nil
}
config.extendedAvailable = true
return config, nil
}
// queryExtendedServiceConfig queries the delayed auto-start flag and the
// triggers of a service with QueryServiceConfig2 into config. buf must not be
// empty; the configuration strings must already be copied out of it.
func queryExtendedServiceConfig(handle windows.Handle, buf *[]byte, config *serviceConfig) error {
// The service control manager ignores the delayed auto-start flag of services
// that don't start automatically, so it is only queried for them.
if config.StartType == windows.SERVICE_AUTO_START {
var (
delayed windows.SERVICE_DELAYED_AUTO_START_INFO
bytesNeeded uint32
)
err := windows.QueryServiceConfig2(handle, windows.SERVICE_CONFIG_DELAYED_AUTO_START_INFO,
(*byte)(unsafe.Pointer(&delayed)), uint32(unsafe.Sizeof(delayed)), &bytesNeeded)
if err != nil {
return fmt.Errorf("failed to query the delayed auto-start flag: %w", err)
}
config.DelayedAutoStart = delayed.IsDelayedAutoStartUp != 0
}
err := queryServiceConfigBuffer(buf, func(data *byte, size uint32, bytesNeeded *uint32) error {
return windows.QueryServiceConfig2(handle, windows.SERVICE_CONFIG_TRIGGER_INFO, data, size, bytesNeeded)
})
if err != nil {
return fmt.Errorf("failed to query the service triggers: %w", err)
}
config.triggerStart = (*serviceTriggerInfo)(unsafe.Pointer(&(*buf)[0])).triggers > 0
return nil
}
// queryServiceConfigBuffer calls query with the buffer and grows the buffer
// until the result fits. query wraps QueryServiceConfig or QueryServiceConfig2.
// The buffer must not be empty.
func queryServiceConfigBuffer(buf *[]byte, query func(data *byte, size uint32, bytesNeeded *uint32) error) error {
for {
var bytesNeeded uint32
err := query(&(*buf)[0], uint32(len(*buf)), &bytesNeeded)
if err == nil {
break
return nil
}
if !errors.Is(err, windows.ERROR_INSUFFICIENT_BUFFER) && !errors.Is(err, windows.ERROR_MORE_DATA) {
return mgr.Config{}, err
return err
}
if bytesNeeded <= uint32(len(*buf)) {
return mgr.Config{}, fmt.Errorf("win32 reports buffer too small (%d), but buffer is large enough (%d): %w", uint32(cap(*buf)), bytesNeeded, err)
return fmt.Errorf("win32 reports the buffer as too small (%d bytes needed, %d available): %w", bytesNeeded, len(*buf), err)
}
*buf = make([]byte, bytesNeeded)
}
defer c.serviceConfigPoolBytes.Put(buf)
return mgr.Config{
BinaryPathName: windows.UTF16PtrToString(serviceConfig.BinaryPathName),
DisplayName: windows.UTF16PtrToString(serviceConfig.DisplayName),
StartType: serviceConfig.StartType,
ServiceStartName: windows.UTF16PtrToString(serviceConfig.ServiceStartName),
}, nil
}
@@ -26,6 +26,7 @@ import (
"strings"
"testing"
"time"
"unsafe"
"github.com/prometheus-community/windows_exporter/internal/scrapecache"
"github.com/prometheus/client_golang/prometheus"
@@ -98,6 +99,40 @@ func serviceMetrics(t assert.TestingT, c *Collector, name string) map[string]flo
return metrics
}
// setStartTrigger gives a service a trigger that starts it once the first IP
// address becomes available. golang.org/x/sys/windows/svc/mgr can't set triggers.
func setStartTrigger(t *testing.T, service *mgr.Service) {
t.Helper()
// NETWORK_MANAGER_FIRST_IP_ADDRESS_ARRIVAL_GUID.
subtype := windows.GUID{Data1: 0x4f27f2de, Data2: 0x14e2, Data3: 0x430b, Data4: [8]byte{0xa5, 0x49, 0x7c, 0xd4, 0x8c, 0xbc, 0x82, 0x45}}
// SERVICE_TRIGGER.
trigger := struct {
triggerType uint32
action uint32
subtype *windows.GUID
dataItems uint32
data uintptr
}{
triggerType: 3, // SERVICE_TRIGGER_TYPE_IP_ADDRESS_AVAILABILITY
action: 1, // SERVICE_TRIGGER_ACTION_SERVICE_START
subtype: &subtype,
}
// SERVICE_TRIGGER_INFO.
info := struct {
triggers uint32
trigger unsafe.Pointer
reserved uintptr
}{
triggers: 1,
trigger: unsafe.Pointer(&trigger),
}
require.NoError(t, windows.ChangeServiceConfig2(service.Handle, windows.SERVICE_CONFIG_TRIGGER_INFO, (*byte)(unsafe.Pointer(&info))))
}
// TestCollectorPicksUpConfigChanges creates a service, changes its configuration
// through the service control manager and checks that the cached configuration
// is replaced. It needs an elevated process, which the Windows CI runners are.
@@ -148,7 +183,7 @@ func TestCollectorPicksUpConfigChanges(t *testing.T) {
metrics := serviceMetrics(t, c, name)
assert.InDelta(t, 1.0, metrics["windows_service_start_mode{start_mode=manual}"], 0)
assert.Contains(t, metrics, `windows_service_info{display_name=before,path_name=C:\Windows\System32\cmd.exe,run_as=LocalSystem}`)
assert.Contains(t, metrics, `windows_service_info{delayed_start=false,display_name=before,path_name=C:\Windows\System32\cmd.exe,run_as=LocalSystem,trigger_start=false}`)
_, _, ok := c.configCache.get(name)
require.True(t, ok, "the configuration must be cached after a scrape")
@@ -156,16 +191,45 @@ func TestCollectorPicksUpConfigChanges(t *testing.T) {
config, err := service.Config()
require.NoError(t, err)
config.StartType = mgr.StartDisabled
// The service is never started. It is switched back to manual start below,
// so that a killed test doesn't leave an auto-start service behind.
config.StartType = mgr.StartAutomatic
config.DelayedAutoStart = true
config.DisplayName = "after"
require.NoError(t, service.UpdateConfig(config))
// The change notification is delivered asynchronously.
require.EventuallyWithT(t, func(collect *assert.CollectT) {
metrics := serviceMetrics(collect, c, name)
assert.InDelta(collect, 1.0, metrics["windows_service_start_mode{start_mode=disabled}"], 0)
assert.InDelta(collect, 1.0, metrics["windows_service_start_mode{start_mode=auto}"], 0)
assert.InDelta(collect, 0.0, metrics["windows_service_start_mode{start_mode=manual}"], 0)
assert.Contains(collect, metrics, `windows_service_info{display_name=after,path_name=C:\Windows\System32\cmd.exe,run_as=LocalSystem}`)
assert.Contains(collect, metrics, `windows_service_info{delayed_start=true,display_name=after,path_name=C:\Windows\System32\cmd.exe,run_as=LocalSystem,trigger_start=false}`)
}, 5*time.Second, 50*time.Millisecond)
// Windows ignores the delayed auto-start flag of a manual service, and so does the label.
config.StartType = mgr.StartManual
require.NoError(t, service.UpdateConfig(config))
require.EventuallyWithT(t, func(collect *assert.CollectT) {
metrics := serviceMetrics(collect, c, name)
assert.InDelta(collect, 1.0, metrics["windows_service_start_mode{start_mode=manual}"], 0)
assert.Contains(collect, metrics, `windows_service_info{delayed_start=false,display_name=after,path_name=C:\Windows\System32\cmd.exe,run_as=LocalSystem,trigger_start=false}`)
}, 5*time.Second, 50*time.Millisecond)
// Cache the changed configuration, so that the trigger must invalidate it.
time.Sleep(configCacheMinKeyAge + 100*time.Millisecond)
serviceMetrics(t, c, name)
_, _, ok = c.configCache.get(name)
require.True(t, ok, "the changed configuration must be cached after a scrape")
// Triggers are stored in the TriggerInfo subkey of the service key. Creating
// it changes the last write time of the service key.
setStartTrigger(t, service)
require.EventuallyWithT(t, func(collect *assert.CollectT) {
metrics := serviceMetrics(collect, c, name)
assert.Contains(collect, metrics, `windows_service_info{delayed_start=false,display_name=after,path_name=C:\Windows\System32\cmd.exe,run_as=LocalSystem,trigger_start=true}`)
}, 5*time.Second, 50*time.Millisecond)
// The service control manager removes the service once the last handle is closed.
@@ -182,3 +246,18 @@ func TestCollectorPicksUpConfigChanges(t *testing.T) {
assert.False(collect, ok, "the configuration of a deleted service must be dropped")
}, 5*time.Second, 50*time.Millisecond)
}
// TestServiceTriggerInfoLayout checks the mirror of SERVICE_TRIGGER_INFO
// (winsvc.h): DWORD cTriggers, PSERVICE_TRIGGER pTriggers and PBYTE pReserved.
// The pointers are pointer-aligned, so cTriggers is padded to a pointer word.
func TestServiceTriggerInfoLayout(t *testing.T) {
t.Parallel()
var info serviceTriggerInfo
pointerSize := unsafe.Sizeof(uintptr(0))
assert.Equal(t, uintptr(0), unsafe.Offsetof(info.triggers))
assert.Equal(t, pointerSize, unsafe.Offsetof(info.trigger))
assert.Equal(t, 3*pointerSize, unsafe.Sizeof(info))
}