scheduled_task: skip filtered folders, exclude Windows tasks and missed runs by default (#2700)

Signed-off-by: Jan-Otto Kröpke <mail@jkroepke.de>
Signed-off-by: Claude <noreply@anthropic.com>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Jan-Otto Kröpke
2026-10-12 21:40:44 +02:00
committed by GitHub
co-authored by Claude
parent ec7dcdc259
commit 9147165ebc
10 changed files with 775 additions and 56 deletions
@@ -20,6 +20,16 @@ which an unprivileged user can read.
- Read only the properties that are published, apply the include/exclude
filter to the path before any service read, and keep property reads
conditional on whether the metric that uses them is emitted.
- `folderFilter` skips subfolders whose tasks the include/exclude expressions
reject whatever their names are, by analyzing the parsed expressions. It must
stay conservative: when in doubt, walk the folder. A Windows 11 desktop has
about 150 folders for about 200 tasks, and listing the tasks and subfolders of
each costs ~0.15 ms per call, so `exclude=/Microsoft/.+` saves most of a scrape.
The default exclude is `/Microsoft/.+` for that reason; keep `ConfigDefaults`
and the flag default in sync through `defaultTaskExclude`.
- `taskReader` carries the task and folder filters and which properties to
read. `NumberOfMissedRuns` is only read and published with `missed-runs`
(off by default), which saves its two RPCs per task.
- The fake native task in `scheduled_task_native_test.go` counts reads per
vtable slot; extend those assertions when adding a property read.
- To find slow calls, time each call type over a full enumeration on a live
@@ -30,3 +40,51 @@ which an unprivileged user can read.
scrape through `internal/metriccache` while a task's path, state, last result
and missed runs are unchanged. Add new inputs of a task's metrics to
`taskValues`, or the cache returns stale series.
## Measured costs and rejected approaches
Measured in October 2026 on a Windows 11 24H2 desktop (taskschd.dll
10.0.26100.5074), unprivileged: 207 tasks in 150 folders (134 leaf folders, 36
of them empty), about 150 ms per scrape without filters.
- Per call: `GetTasks` ~170 µs and `GetFolders` ~145 µs per folder; `State` and
`LastTaskResult` ~130 µs per task; `NumberOfMissedRuns` ~310 µs per task
(~560 µs for disabled tasks). `Path` and folder `Path` take 1–2 µs (local).
`NumberOfMissedRuns` alone is about 28% of a scrape.
- `NumberOfMissedRuns` is computed with two RPCs: `SchRpcGetLastRunInfo`
(opnum 16), then `SchRpcScheduledRuntimes` (opnum 15) from the start of the last run
to the current local time with an unlimited `cRequested`. The service
computes every scheduled run in that window from the task definition and
returns all of them, so tasks with many missed runs (13,146 seen) send large
responses each scrape. Tasks without time triggers return
`SCHED_S_TASK_NOT_SCHEDULED`. `LastTaskResult` also calls
`SchRpcGetLastRunInfo`, so reading both costs one duplicate round trip.
`SchRpcGetNumberOfMissedRuns` (opnum 18) always returns 0 and is not what COM
uses.
- Missed runs are rare but real: 6 of 207 tasks were non-zero, 2 of them
disabled. Skipping the read for disabled tasks would change published values.
- A persistent COM connection (dedicated thread, service kept connected) saves
~1.7 ms per scrape. `CoCreateInstance` and `Connect` are cheap; the service's
per-call time dominates.
- Direct MS-TSCH RPC over `ncalrpc` (hand-marshalled NDR, `I_RpcSendReceive`)
is only 5–8 µs faster per call. It would only save the duplicate
`SchRpcGetLastRunInfo` (~18 ms per scrape, ~12%) plus ~9 ms of client CPU, at
the cost of a hand-written NDR client. Not worth it. Note that
`SchRpcGetTaskInfo` only returns the state with `SCH_FLAG_STATE`
(`0x10000000`).
- One `SchRpcEnumInstances` for all running tasks took ~21 ms, slower than the
per-task state reads.
- There is no flat or recursive task enumeration: `GetTasks`/`GetFolders` and
`SchRpcEnumTasks`/`SchRpcEnumFolders` list one folder each, with only the
hidden-task flag. The registry `TaskCache` is undocumented and not readable
without admin rights.
- `MSFT_ScheduledTask` (CIM, `root/Microsoft/Windows/TaskScheduler`) returns a
flat list with path and state, but is about 3× slower: ~440 ms through
`internal/mi` in-process for the same 207 tasks, whatever properties are
selected, plus ~120 ms for last result and missed runs. The work moves into
`WmiPrvSE` (up to ~30% of one core while querying).
- `missed_runs` is opt-in through `missed-runs`. Not taken yet: a cache
duration that serves the last task list.
- Profiling the exporter with pprof on Windows attributes the time blocked in
these COM calls to `runtime.cgocall`; that is waiting, not exporter CPU. The
Schedule service's own CPU needs an elevated reader of its process times.
@@ -0,0 +1,177 @@
// 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 scheduled_task
import (
"regexp"
"regexp/syntax"
"strings"
)
// folderFilter skips task folders whose tasks the include and exclude filters
// reject whatever their names are. Listing the tasks and subfolders of a folder
// costs two round trips to the Task Scheduler service, so a filter such as
// exclude="/Microsoft/.+" saves the walk of most folders of a Windows install.
//
// The analysis is conservative: it skips a folder only when the expression
// proves that no task below it passes, and keeps every folder otherwise. Task
// paths cannot contain line breaks, because tasks are stored as files below
// %SystemRoot%\System32\Tasks, so "." matches every character of a path.
type folderFilter struct {
// include and exclude are nil when the expression gives no folder information.
include *syntax.Regexp
exclude *syntax.Regexp
}
func newFolderFilter(include, exclude *regexp.Regexp) folderFilter {
return folderFilter{include: parseFilter(include), exclude: parseFilter(exclude)}
}
func parseFilter(re *regexp.Regexp) *syntax.Regexp {
if re == nil {
return nil
}
// regexp.Compile parses with syntax.Perl.
parsed, err := syntax.Parse(re.String(), syntax.Perl)
if err != nil {
return nil
}
return parsed.Simplify()
}
// includeFolder reports whether a task below the folder with the given
// slash-separated path may pass both filters.
func (f folderFilter) includeFolder(path string) bool {
prefix := strings.TrimSuffix(path, "/") + "/"
if f.include != nil && !mayMatchPrefixed([]*syntax.Regexp{f.include}, prefix, false) {
return false
}
return f.exclude == nil || !matchesAllPrefixed([]*syntax.Regexp{f.exclude}, prefix, true)
}
// mayMatchPrefixed reports false only if no string that starts with prefix
// matches the sequence of expressions. Without a start anchor a match can begin
// anywhere, so only anchored expressions rule out a prefix.
func mayMatchPrefixed(seq []*syntax.Regexp, prefix string, anchored bool) bool {
for i, re := range seq {
if prefix == "" {
return true
}
switch re.Op {
case syntax.OpBeginText, syntax.OpBeginLine:
if i == 0 && !anchored {
anchored = true
continue
}
return true
case syntax.OpCapture, syntax.OpConcat:
return mayMatchPrefixed(splice(re.Sub, seq[i+1:]), prefix, anchored)
case syntax.OpAlternate:
for _, sub := range re.Sub {
if mayMatchPrefixed(splice([]*syntax.Regexp{sub}, seq[i+1:]), prefix, anchored) {
return true
}
}
return false
case syntax.OpLiteral:
if !anchored || re.Flags&syntax.FoldCase != 0 {
return true
}
literal := string(re.Rune)
if !strings.HasPrefix(prefix, literal) {
return strings.HasPrefix(literal, prefix)
}
prefix = prefix[len(literal):]
case syntax.OpEndText, syntax.OpEndLine:
// Task paths continue after the folder prefix and have no line breaks.
return !anchored
default:
return true
}
}
return true
}
// matchesAllPrefixed reports true only if every string that starts with prefix
// and continues with at least one character matches the sequence of expressions.
// A full match implies an unanchored match, so start anchors are not required.
func matchesAllPrefixed(seq []*syntax.Regexp, prefix string, atStart bool) bool {
for i, re := range seq {
switch re.Op {
case syntax.OpBeginText, syntax.OpBeginLine:
if atStart {
continue
}
return false
case syntax.OpCapture, syntax.OpConcat:
return matchesAllPrefixed(splice(re.Sub, seq[i+1:]), prefix, atStart)
case syntax.OpAlternate:
for _, sub := range re.Sub {
if matchesAllPrefixed(splice([]*syntax.Regexp{sub}, seq[i+1:]), prefix, atStart) {
return true
}
}
return false
case syntax.OpLiteral:
literal := string(re.Rune)
if re.Flags&syntax.FoldCase != 0 || !strings.HasPrefix(prefix, literal) {
return false
}
prefix = prefix[len(literal):]
atStart = atStart && literal == ""
case syntax.OpStar, syntax.OpPlus:
// ".*" or ".+" as the last expression matches the rest of the path,
// which is never empty.
op := re.Sub[0].Op
return (op == syntax.OpAnyChar || op == syntax.OpAnyCharNotNL) && onlyEndAnchors(seq[i+1:])
default:
return false
}
}
return false
}
func onlyEndAnchors(seq []*syntax.Regexp) bool {
for _, re := range seq {
if re.Op != syntax.OpEndText && re.Op != syntax.OpEndLine {
return false
}
}
return true
}
func splice(head, tail []*syntax.Regexp) []*syntax.Regexp {
return append(append(make([]*syntax.Regexp, 0, len(head)+len(tail)), head...), tail...)
}
@@ -0,0 +1,215 @@
// 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 scheduled_task
import (
"fmt"
"regexp"
"testing"
"github.com/stretchr/testify/require"
)
func TestFolderFilter(t *testing.T) {
t.Parallel()
// The flags anchor the expressions like this.
flag := func(expr string) *regexp.Regexp {
return regexp.MustCompile(fmt.Sprintf("^(?:%s)$", expr))
}
for _, tc := range []struct {
name string
include *regexp.Regexp
exclude *regexp.Regexp
included []string
skipped []string
}{
{
name: "defaults",
include: ConfigDefaults.TaskInclude,
exclude: ConfigDefaults.TaskExclude,
included: []string{"/Other"},
skipped: []string{"/Microsoft", "/Microsoft/Windows"},
},
{
name: "flag defaults",
include: flag(".+"),
exclude: flag(defaultTaskExclude),
included: []string{"/Other"},
skipped: []string{"/Microsoft", "/Microsoft/Windows"},
},
{
name: "empty exclude",
include: flag(".+"),
exclude: flag(""),
included: []string{"/Microsoft", "/Other"},
},
{
name: "nil filters",
included: []string{"/Microsoft"},
},
{
name: "exclude subtree",
include: flag(".+"),
exclude: flag("/Microsoft/.+"),
included: []string{"/Other", "/MicrosoftEdge", "/Microsofx"},
skipped: []string{"/Microsoft", "/Microsoft/Windows", "/Microsoft/Windows/Defrag"},
},
{
name: "exclude deeper subtree",
include: flag(".+"),
exclude: flag("/Microsoft/Windows/.*"),
included: []string{"/Microsoft", "/Microsoft/Office", "/Microsoft/WindowsUpdate"},
skipped: []string{"/Microsoft/Windows", "/Microsoft/Windows/Defrag"},
},
{
name: "exclude alternatives",
include: flag(".+"),
exclude: flag("/Microsoft/.*|/Mozilla/.+"),
included: []string{"/Other", "/Mozillas"},
skipped: []string{"/Microsoft/Windows", "/Mozilla", "/Mozilla/Firefox"},
},
{
name: "exclude capture group",
exclude: regexp.MustCompile(`^(/Microsoft/.*)$`),
included: []string{"/Other"},
skipped: []string{"/Microsoft/Windows"},
},
{
name: "unanchored exclude",
exclude: regexp.MustCompile(`/Microsoft/.*`),
included: []string{"/Other", "/Vendor"},
skipped: []string{"/Microsoft"},
},
{
name: "exclude every task",
exclude: flag(".*"),
skipped: []string{"/Microsoft", "/Other"},
},
{
name: "exclude without a full subtree",
exclude: flag(
"/Microsoft/[^/]+|/Microsoft/Windows/.+/Task|/Microsoft/Windows|(?i)/microsoft/.+|/Other/.+x|/Other/\n.+|/Other/(?:^.+)",
),
included: []string{"/Microsoft", "/Microsoft/Windows", "/Other"},
},
{
name: "include subtree",
include: flag("/Vendor/App/.+"),
included: []string{"/Vendor", "/Vendor/App", "/Vendor/App/Sub"},
skipped: []string{"/Microsoft", "/Vendor/Other", "/Vendo"},
},
{
name: "include alternatives",
include: flag("/Vendor/.+|/Other/Task"),
included: []string{"/Vendor", "/Other"},
skipped: []string{"/Microsoft", "/Other/Sub"},
},
{
name: "include with folder prefix",
include: flag("/Vend.*"),
included: []string{"/Vendor", "/Vendor/App", "/Vend"},
skipped: []string{"/Microsoft"},
},
{
name: "unanchored include",
include: regexp.MustCompile(`Firefox`),
included: []string{"/Microsoft", "/Mozilla"},
},
{
name: "case-insensitive include",
include: flag("(?i)/vendor/.+"),
included: []string{"/Vendor", "/Microsoft"},
},
{
name: "include and exclude",
include: flag("/Microsoft/.+"),
exclude: flag("/Microsoft/Windows/.+"),
included: []string{"/Microsoft", "/Microsoft/Office"},
skipped: []string{"/Other", "/Microsoft/Windows", "/Microsoft/Windows/Defrag"},
},
{
name: "exclude listed subfolders",
exclude: flag("/Microsoft/Windows/(?:Defrag|Diagnosis|WindowsUpdate)/.+"),
included: []string{"/Microsoft", "/Microsoft/Windows", "/Microsoft/Windows/Other", "/Other"},
skipped: []string{"/Microsoft/Windows/Defrag", "/Microsoft/Windows/Diagnosis", "/Microsoft/Windows/WindowsUpdate"},
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
filter := newFolderFilter(tc.include, tc.exclude)
for _, path := range tc.included {
require.True(t, filter.includeFolder(path), path)
}
for _, path := range tc.skipped {
require.False(t, filter.includeFolder(path), path)
}
})
}
}
// TestFolderFilterSkipsOnlyFilteredTasks checks the folder decision against the
// task filters for task paths below each folder.
func TestFolderFilterSkipsOnlyFilteredTasks(t *testing.T) {
t.Parallel()
expressions := []string{
"", ".+", ".*", "/Microsoft/.+", "/Microsoft/.*", "/Microsoft/Windows/.+", "/Microsoft/.+|/Other/.*",
"/Microsoft/Windows", "/Microsoft/[^/]+", "/Micro.*", "(?i)/microsoft/.+", "/Other/Task", "/Other/.+|",
`\A/Microsoft/.+\z`, "(?m)^/Microsoft/.*$", "(?s)/Microsoft/.+", "(/Microsoft/.+)", "^/Micro|/Other/.+",
"/Microsoft/Windows/(?:Defrag|Diagnosis)/.+", "/Microsoft/Windows/Defrag/Task",
}
folders := []string{
"/Microsoft", "/Microsoft/Windows", "/Microsoft/Windows/Defrag", "/Microsoft/Windows/Diagnosis", "/MicrosoftEdge", "/Other",
}
names := []string{"Task", "Sub/Task", "x"}
// The flags anchor the expressions, Config values passed to New need not be.
regexps := make([]*regexp.Regexp, 0, 2*len(expressions))
for _, expr := range expressions {
regexps = append(regexps, regexp.MustCompile(fmt.Sprintf("^(?:%s)$", expr)), regexp.MustCompile(expr))
}
skipped := 0
for _, include := range regexps {
for _, exclude := range regexps {
c := New(&Config{TaskInclude: include, TaskExclude: exclude})
filter := newFolderFilter(c.config.TaskInclude, c.config.TaskExclude)
for _, folder := range folders {
if filter.includeFolder(folder) {
continue
}
skipped++
for _, name := range names {
path := folder + "/" + name
require.False(t, c.includeTask(path), "include %q exclude %q path %q", include, exclude, path)
}
}
}
}
require.NotZero(t, skipped)
}
@@ -23,6 +23,7 @@ import (
"log/slog"
"regexp"
"runtime"
"strconv"
"strings"
"time"
@@ -40,12 +41,18 @@ const Name = "scheduled_task"
type Config struct {
TaskExclude *regexp.Regexp `yaml:"exclude"`
TaskInclude *regexp.Regexp `yaml:"include"`
MissedRuns bool `yaml:"missed-runs"`
}
// defaultTaskExclude excludes the tasks of Windows itself, which are most of
// the tasks and folders of an installation.
const defaultTaskExclude = "/Microsoft/.+"
//nolint:gochecknoglobals
var ConfigDefaults = Config{
TaskExclude: types.RegExpEmpty,
TaskExclude: regexp.MustCompile("^(?:" + defaultTaskExclude + ")$"),
TaskInclude: types.RegExpAny,
MissedRuns: false,
}
type Collector struct {
@@ -57,6 +64,9 @@ type Collector struct {
missedRuns *prometheus.Desc
state *prometheus.Desc
// folderFilter skips folders that cannot hold tasks passing the filters.
folderFilter folderFilter
// metricCache reuses the metrics of tasks whose values did not change since
// the last scrape. Each task publishes 19 series, and building them is most
// of the allocations of a scrape.
@@ -139,14 +149,21 @@ func NewWithFlags(app *kingpin.Application) *Collector {
app.Flag(
"collector.scheduled_task.exclude",
"Regexp of tasks to exclude. Task path must both match include and not match exclude to be included.",
).Default("").StringVar(&taskExclude)
"Regexp of tasks to exclude. Task path must both match include and not match exclude to be included. "+
"The default excludes the tasks of Windows itself.",
).Default(defaultTaskExclude).StringVar(&taskExclude)
app.Flag(
"collector.scheduled_task.include",
"Regexp of tasks to include. Task path must both match include and not match exclude to be included.",
).Default(".+").StringVar(&taskInclude)
app.Flag(
"collector.scheduled_task.missed-runs",
"Read and publish windows_scheduled_task_missed_runs. "+
"Reading the missed runs of a task costs the Task Scheduler service two requests per scrape.",
).Default(strconv.FormatBool(ConfigDefaults.MissedRuns)).BoolVar(&c.config.MissedRuns)
app.Action(func(*kingpin.ParseContext) error {
var err error
@@ -208,6 +225,8 @@ func (c *Collector) Build(logger *slog.Logger, _ *mi.Session) error {
nil,
)
c.folderFilter = newFolderFilter(c.config.TaskInclude, c.config.TaskExclude)
// Cached metrics refer to the descriptors of an earlier Build.
c.metricCache.Reset()
@@ -228,7 +247,7 @@ var TASK_RESULT_STATUSES = []string{
}
func (c *Collector) collect(ch chan<- prometheus.Metric) error {
scheduledTasks, err := getScheduledTasks(c.includeTask)
scheduledTasks, err := c.taskReader().getScheduledTasks()
if errors.Is(err, errTasksSkipped) {
// Tasks and folders that can't be read are skipped, the other tasks are still collected.
c.logger.Warn("failed to read some scheduled tasks",
@@ -248,6 +267,14 @@ func (c *Collector) includeTask(path string) bool {
return c.config.TaskInclude.MatchString(path) && !c.config.TaskExclude.MatchString(path)
}
func (c *Collector) taskReader() taskReader {
return taskReader{
includeTask: c.includeTask,
includeFolder: c.folderFilter.includeFolder,
missedRuns: c.config.MissedRuns,
}
}
func (c *Collector) collectMetrics(ch chan<- prometheus.Metric, scheduledTasks ScheduledTasks) {
scrape := c.metricCache.Begin()
@@ -317,27 +344,39 @@ func (c *Collector) taskMetrics(task ScheduledTask) []prometheus.Metric {
lastResult = 1.0
}
return append(metrics,
prometheus.MustNewConstMetric(
c.lastResult,
prometheus.GaugeValue,
lastResult,
task.Path,
),
prometheus.MustNewConstMetric(
c.missedRuns,
prometheus.GaugeValue,
task.MissedRunsCount,
task.Path,
),
)
metrics = append(metrics, prometheus.MustNewConstMetric(
c.lastResult,
prometheus.GaugeValue,
lastResult,
task.Path,
))
if !c.config.MissedRuns {
return metrics
}
return append(metrics, prometheus.MustNewConstMetric(
c.missedRuns,
prometheus.GaugeValue,
task.MissedRunsCount,
task.Path,
))
}
// errTasksSkipped accompanies readable tasks when some tasks or folders could not be read.
var errTasksSkipped = errors.New("tasks skipped")
// getScheduledTasks returns the tasks whose slash-separated paths pass include.
func getScheduledTasks(include func(path string) bool) (ScheduledTasks, error) {
// taskReader selects the tasks and folders to read and the task properties to read.
type taskReader struct {
// includeTask and includeFolder take slash-separated paths.
includeTask func(path string) bool
includeFolder func(path string) bool
missedRuns bool
}
// getScheduledTasks returns the tasks that pass includeTask. It does not walk
// the subfolders that fail includeFolder.
func (r taskReader) getScheduledTasks() (ScheduledTasks, error) {
// COM initialization and every interface call stay on the same OS thread.
runtime.LockOSThread()
@@ -366,7 +405,7 @@ func getScheduledTasks(include func(path string) bool) (ScheduledTasks, error) {
defer root.Release()
tasks := ScheduledTasks{}
if err := fetchTasksRecursively(root, `\`, include, &tasks); err != nil {
if err := r.fetchTasksRecursively(root, `\`, &tasks); err != nil {
return tasks, fmt.Errorf("%w: %w", errTasksSkipped, err)
}
@@ -374,7 +413,7 @@ func getScheduledTasks(include func(path string) bool) (ScheduledTasks, error) {
}
// fetchTasksInFolder appends readable tasks and reports errors after reading the remaining tasks.
func fetchTasksInFolder(folder *taskschd.TaskFolder, include func(path string) bool, scheduledTasks *ScheduledTasks) error {
func (r taskReader) fetchTasksInFolder(folder *taskschd.TaskFolder, scheduledTasks *ScheduledTasks) error {
tasks, err := folder.Tasks()
if err != nil {
return fmt.Errorf("get tasks: %w", err)
@@ -390,7 +429,7 @@ func fetchTasksInFolder(folder *taskschd.TaskFolder, include func(path string) b
continue
}
parsedTask, ok, err := parseTask(task, include)
parsedTask, ok, err := r.parseTask(task)
if err != nil {
errs = append(errs, fmt.Errorf("parse task: %w", err))
@@ -408,11 +447,11 @@ func fetchTasksInFolder(folder *taskschd.TaskFolder, include func(path string) b
}
// fetchTasksRecursively appends readable tasks, retaining errors from skipped folders or tasks.
func fetchTasksRecursively(
folder *taskschd.TaskFolder, folderPath string, include func(path string) bool, scheduledTasks *ScheduledTasks,
) error {
// Subfolders that fail includeFolder are not walked, which saves two round trips
// to the Task Scheduler service per folder.
func (r taskReader) fetchTasksRecursively(folder *taskschd.TaskFolder, folderPath string, scheduledTasks *ScheduledTasks) error {
errs := []error{}
if err := fetchTasksInFolder(folder, include, scheduledTasks); err != nil {
if err := r.fetchTasksInFolder(folder, scheduledTasks); err != nil {
errs = append(errs, fmt.Errorf("folder %s: %w", folderPath, err))
}
@@ -429,12 +468,14 @@ func fetchTasksRecursively(
continue
}
subfolderPath := folderPath
if path, err := subfolder.Path(); err == nil {
subfolderPath = path
subfolderPath, err := subfolder.Path()
if err != nil {
subfolderPath = folderPath
} else if !r.includeFolder(strings.ReplaceAll(subfolderPath, "\\", "/")) {
continue
}
if err := fetchTasksRecursively(subfolder, subfolderPath, include, scheduledTasks); err != nil {
if err := r.fetchTasksRecursively(subfolder, subfolderPath, scheduledTasks); err != nil {
errs = append(errs, err)
}
}
@@ -442,18 +483,18 @@ func fetchTasksRecursively(
return errors.Join(errs...)
}
// parseTask reads a task that passes include and reports false for filtered
// parseTask reads a task that passes includeTask and reports false for filtered
// tasks. Reading a task property other than Path is a round trip to the Task
// Scheduler service, so filtered tasks are skipped before those reads, and only
// properties that collectMetrics publishes are read.
func parseTask(task *taskschd.RegisteredTask, include func(path string) bool) (ScheduledTask, bool, error) {
func (r taskReader) parseTask(task *taskschd.RegisteredTask) (ScheduledTask, bool, error) {
path, err := task.Path()
if err != nil {
return ScheduledTask{}, false, fmt.Errorf("get task path: %w", err)
}
parsedTask := ScheduledTask{Path: strings.ReplaceAll(path, "\\", "/")}
if !include(parsedTask.Path) {
if !r.includeTask(parsedTask.Path) {
return ScheduledTask{}, false, nil
}
@@ -472,7 +513,7 @@ func parseTask(task *taskschd.RegisteredTask, include func(path string) bool) (S
parsedTask.LastTaskResult = TaskResult(result)
// collectMetrics omits missed runs for tasks that have not run.
if parsedTask.LastTaskResult != SCHED_S_TASK_HAS_NOT_RUN {
if r.missedRuns && parsedTask.LastTaskResult != SCHED_S_TASK_HAS_NOT_RUN {
missed, err := task.NumberOfMissedRuns()
if err != nil {
return ScheduledTask{}, false, fmt.Errorf("get task missed runs: %w", err)
@@ -23,6 +23,7 @@ import (
"strings"
"testing"
"github.com/alecthomas/kingpin/v2"
"github.com/prometheus/client_golang/prometheus"
dto "github.com/prometheus/client_model/go"
"github.com/prometheus/common/expfmt"
@@ -113,7 +114,7 @@ func TestCollectMetrics(t *testing.T) {
MissedRunsCount: 3,
LastTaskResult: TaskResult(tc.result),
}
families := gatherTaskMetrics(t, nil, ScheduledTasks{task})
families := gatherTaskMetrics(t, &Config{MissedRuns: true}, ScheduledTasks{task})
states := families["windows_scheduled_task_state"]
require.NotNil(t, states)
@@ -243,7 +244,7 @@ func TestCollectMetricsCache(t *testing.T) {
{Path: "/Added", State: TASK_STATE_QUEUED, LastTaskResult: SCHED_S_TASK_QUEUED},
}
c := New(nil)
c := New(&Config{MissedRuns: true})
require.NoError(t, c.Build(slog.New(slog.DiscardHandler), nil))
var previous map[string][]prometheus.Metric
@@ -387,3 +388,37 @@ func benchmarkTasks(n int) ScheduledTasks {
return tasks
}
func TestNewWithFlagsExclude(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
args []string
update bool
defrag bool
}{
{name: "default"},
{name: "empty", args: []string{"--collector.scheduled_task.exclude="}, update: true, defrag: true},
{name: "empty separate value", args: []string{"--collector.scheduled_task.exclude", ""}, update: true, defrag: true},
{
name: "listed folders",
args: []string{"--collector.scheduled_task.exclude=/Microsoft/Windows/(?:Defrag|Diagnosis)/.+"},
update: true,
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
app := kingpin.New("test", "test")
c := NewWithFlags(app)
_, err := app.Parse(tc.args)
require.NoError(t, err)
require.True(t, c.includeTask("/Vendor/Task"))
require.Equal(t, tc.update, c.includeTask("/Microsoft/Windows/UpdateOrchestrator/Schedule Scan"))
require.Equal(t, tc.defrag, c.includeTask("/Microsoft/Windows/Defrag/ScheduledDefrag"))
})
}
}
@@ -18,6 +18,8 @@
package scheduled_task
import (
"fmt"
"log/slog"
"regexp"
"slices"
"strings"
@@ -46,7 +48,7 @@ func TestFetchTasksInFolderPartialCollection(t *testing.T) {
folder := fakeTaskFolder(collection, nil, nil)
tasks := ScheduledTasks{}
err := fetchTasksInFolder(folder, includeAllTasks, &tasks)
err := readAllTasks.fetchTasksInFolder(folder, &tasks)
require.ErrorIs(t, err, ole.HRESULT(0x80070005))
require.Equal(t, 1, collectionsReleased)
@@ -84,7 +86,7 @@ func TestFetchTasksRecursivelyPartialFolderCollection(t *testing.T) {
root := fakeTaskFolder(emptyTasks, folders, nil)
tasks := ScheduledTasks{}
err := fetchTasksRecursively(root, `\`, includeAllTasks, &tasks)
err := readAllTasks.fetchTasksRecursively(root, `\`, &tasks)
require.ErrorIs(t, err, ole.HRESULT(0x80070005))
require.Equal(t, []int64{1, 2, 3}, *attempted)
require.Len(t, tasks, 2)
@@ -95,13 +97,76 @@ func TestFetchTasksRecursivelyPartialFolderCollection(t *testing.T) {
require.Equal(t, 6, collectionsReleased)
}
func TestFetchTasksRecursivelySkipsFilteredFolders(t *testing.T) {
for _, tc := range []struct {
name string
config *Config
paths []string
listedMicrosoft int
}{
{name: "default exclude", paths: []string{"/Task", "/Vendor/Task"}},
{
name: "empty exclude",
config: &Config{TaskExclude: regexp.MustCompile(`^(?:)$`)},
paths: []string{"/Task", "/Microsoft/Task", "/Vendor/Task"}, listedMicrosoft: 2,
},
} {
t.Run(tc.name, func(t *testing.T) {
var released, collectionsReleased, foldersReleased, listedMicrosoft, listedVendor int
fakes := []*fakeTask{
{path: `\Microsoft\Task`, state: int32(TASK_STATE_READY)},
{path: `\Vendor\Task`, state: int32(TASK_STATE_READY)},
{path: `\Task`, state: int32(TASK_STATE_READY)},
}
folder := func(path string, task *fakeTask, listed *int) *ole.Object {
tasks, _ := fakeTaskCollection([]*ole.Object{task.object(&released)}, false, &collectionsReleased)
folders, _ := fakeTaskCollection(nil, false, &collectionsReleased)
return &fakeTaskFolderAt(path, tasks, folders, &foldersReleased, listed).Object
}
subfolders, _ := fakeTaskCollection([]*ole.Object{
folder(`\Microsoft`, fakes[0], &listedMicrosoft),
folder(`\Vendor`, fakes[1], &listedVendor),
}, false, &collectionsReleased)
rootTasks, _ := fakeTaskCollection([]*ole.Object{fakes[2].object(&released)}, false, &collectionsReleased)
root := fakeTaskFolderAt(`\`, rootTasks, subfolders, nil, nil)
c := New(tc.config)
require.NoError(t, c.Build(slog.New(slog.DiscardHandler), nil))
tasks := ScheduledTasks{}
require.NoError(t, c.taskReader().fetchTasksRecursively(root, `\`, &tasks))
paths := make([]string, 0, len(tasks))
for _, task := range tasks {
require.Equal(t, TASK_STATE_READY, task.State)
paths = append(paths, task.Path)
}
require.ElementsMatch(t, tc.paths, paths)
// An excluded folder is released without listing its tasks or subfolders.
require.Equal(t, tc.listedMicrosoft, listedMicrosoft)
require.Equal(t, 2, listedVendor)
require.Equal(t, 2, foldersReleased)
require.Equal(t, tc.listedMicrosoft/2, fakes[0].reads[taskSlotPath])
require.Equal(t, 1, fakes[1].reads[taskSlotState])
})
}
}
func TestFetchTasksFilters(t *testing.T) {
for _, tc := range []struct {
name string
config *Config
paths []string
}{
{name: "defaults", paths: []string{"/Included/Task", "/Included/Excluded", "/Other/Task"}},
{name: "defaults", config: &Config{}, paths: []string{"/Included/Task", "/Included/Excluded", "/Other/Task"}},
{
name: "include",
config: &Config{TaskInclude: regexp.MustCompile(`^/Included/`)},
@@ -138,7 +203,9 @@ func TestFetchTasksFilters(t *testing.T) {
collection, _ := fakeTaskCollection(items, false, &collectionsReleased)
tasks := ScheduledTasks{}
err := fetchTasksInFolder(fakeTaskFolder(collection, nil, nil), New(tc.config).includeTask, &tasks)
tc.config.MissedRuns = true
err := New(tc.config).taskReader().fetchTasksInFolder(fakeTaskFolder(collection, nil, nil), &tasks)
require.NoError(t, err)
require.Equal(t, len(fakes), released)
@@ -155,7 +222,7 @@ func TestFetchTasksFilters(t *testing.T) {
require.Equal(t, reads, task.reads[taskSlotMissedRuns], task.path)
}
families := gatherTaskMetrics(t, nil, tasks)
families := gatherTaskMetrics(t, tc.config, tasks)
if len(tc.paths) == 0 {
require.Empty(t, families)
@@ -210,7 +277,7 @@ func TestFetchTasksInFolderReadsPublishedProperties(t *testing.T) {
)
tasks := ScheduledTasks{}
require.NoError(t, fetchTasksInFolder(fakeTaskFolder(collection, nil, nil), includeAllTasks, &tasks))
require.NoError(t, readAllTasks.fetchTasksInFolder(fakeTaskFolder(collection, nil, nil), &tasks))
require.Equal(t, ScheduledTasks{
{Path: "/Folder/Ran", State: TASK_STATE_READY, LastTaskResult: 1, MissedRunsCount: 2},
{Path: "/NotRun", State: TASK_STATE_DISABLED, LastTaskResult: SCHED_S_TASK_HAS_NOT_RUN},
@@ -232,13 +299,32 @@ func TestFetchTasksInFolderReadsPublishedProperties(t *testing.T) {
require.Equal(t, missedRuns, task.reads[taskSlotMissedRuns], task.path)
}
missedRuns := gatherTaskMetrics(t, nil, tasks)["windows_scheduled_task_missed_runs"]
missedRuns := gatherTaskMetrics(t, &Config{MissedRuns: true}, tasks)["windows_scheduled_task_missed_runs"]
require.NotNil(t, missedRuns)
require.Len(t, missedRuns.GetMetric(), 1)
require.Equal(t, "/Folder/Ran", missedRuns.GetMetric()[0].GetLabel()[0].GetValue())
require.InDelta(t, 2, missedRuns.GetMetric()[0].GetGauge().GetValue(), 0)
}
// Missed runs are not read or published by default.
func TestFetchTasksInFolderSkipsMissedRuns(t *testing.T) {
var released, collectionsReleased int
task := &fakeTask{path: `\Task`, state: int32(TASK_STATE_READY), result: 1, missed: 2}
collection, _ := fakeTaskCollection([]*ole.Object{task.object(&released)}, false, &collectionsReleased)
config := &Config{}
tasks := ScheduledTasks{}
require.NoError(t, New(config).taskReader().fetchTasksInFolder(fakeTaskFolder(collection, nil, nil), &tasks))
require.Equal(t, ScheduledTasks{{Path: "/Task", State: TASK_STATE_READY, LastTaskResult: 1}}, tasks)
require.Equal(t, 1, task.reads[taskSlotLastResult])
require.Zero(t, task.reads[taskSlotMissedRuns])
families := gatherTaskMetrics(t, config, tasks)
require.Contains(t, families, "windows_scheduled_task_last_result")
require.NotContains(t, families, "windows_scheduled_task_missed_runs")
}
// The fake native collections use the Task Scheduler's one-based VARIANT indices.
func fakeTaskCollection(items []*ole.Object, countError bool, released *int) (*ole.Object, *[]int64) {
var methods [9]uintptr
@@ -275,8 +361,20 @@ func fakeTaskCollection(items []*ole.Object, countError bool, released *int) (*o
}
func fakeTaskFolder(tasks, folders *ole.Object, released *int) *taskschd.TaskFolder {
return fakeTaskFolderAt("", tasks, folders, released, nil)
}
// fakeTaskFolderAt returns a folder with the given path that counts the
// listings of its tasks and subfolders in listed when it is not nil.
func fakeTaskFolderAt(path string, tasks, folders *ole.Object, released, listed *int) *taskschd.TaskFolder {
var methods [15]uintptr
count := func() {
if listed != nil {
*listed++
}
}
methods[2] = windows.NewCallback(func(uintptr) uintptr {
if released != nil {
*released++
@@ -284,17 +382,17 @@ func fakeTaskFolder(tasks, folders *ole.Object, released *int) *taskschd.TaskFol
return 0
})
methods[8] = windows.NewCallback(func(_ uintptr, out *uintptr) uintptr {
*out = 0 // A null BSTR is a valid empty string.
return 0
})
methods[8] = (&fakeTask{}).stringGetter(8, &path) // A null BSTR is a valid empty string.
methods[10] = windows.NewCallback(func(_ uintptr, _ uintptr, out **ole.Object) uintptr {
count()
*out = folders
return 0
})
methods[14] = windows.NewCallback(func(_ uintptr, _ uintptr, out **ole.Object) uintptr {
count()
*out = tasks
return 0
@@ -387,11 +485,27 @@ var sysAllocString = windows.NewLazySystemDLL("oleaut32.dll").NewProc("SysAllocS
func includeAllTasks(string) bool { return true }
//nolint:gochecknoglobals
var readAllTasks = taskReader{includeTask: includeAllTasks, includeFolder: includeAllTasks, missedRuns: true}
// BenchmarkGetScheduledTasks measures the Task Scheduler reads without metric creation.
func BenchmarkGetScheduledTasks(b *testing.B) {
for b.Loop() {
if _, err := getScheduledTasks(includeAllTasks); err != nil {
b.Fatal(err)
}
for _, bc := range []struct {
name string
exclude string
}{
{name: "all"},
{name: "exclude Microsoft", exclude: "/Microsoft/.+"},
} {
b.Run(bc.name, func(b *testing.B) {
c := New(&Config{TaskExclude: regexp.MustCompile(fmt.Sprintf("^(?:%s)$", bc.exclude))})
require.NoError(b, c.Build(slog.New(slog.DiscardHandler), nil))
for b.Loop() {
if _, err := c.taskReader().getScheduledTasks(); err != nil {
b.Fatal(err)
}
}
})
}
}
+34
View File
@@ -489,3 +489,37 @@ func TestParse(t *testing.T) {
})
}
}
// TestParseEmptyValue verifies that an empty value in the configuration file
// replaces a flag's non-empty default instead of keeping it.
func TestParseEmptyValue(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
config string
extraArgs []string
want string
}{
{name: "default", config: "collector:\n scheduled_task:\n", want: "/Microsoft/.+"},
{name: "empty string", config: "collector:\n scheduled_task:\n exclude: \"\"\n", want: ""},
{
name: "CLI overrides config", config: "collector:\n scheduled_task:\n exclude: /Vendor/.+\n",
extraArgs: []string{"--collector.scheduled_task.exclude="}, want: "",
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "config.yaml")
require.NoError(t, os.WriteFile(path, []byte(tc.config), 0o600))
app := kingpin.New("test", "test")
app.Flag("config.file", "config").String()
collector.NewWithFlags(app)
require.NoError(t, config.Parse(app, append([]string{"--config.file=" + path}, tc.extraArgs...)))
require.Equal(t, tc.want, app.GetFlag("collector.scheduled_task.exclude").Model().Value.String())
})
}
}