scheduled_task: expose result statuses and deprecate last_result (#2532)

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:
Jan-Otto Kröpke
2026-10-07 23:26:47 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 88034b818e
commit 92483242ba
4 changed files with 492 additions and 19 deletions
@@ -50,16 +50,19 @@ var ConfigDefaults = Config{
type Collector struct {
config Config
lastResult *prometheus.Desc
missedRuns *prometheus.Desc
state *prometheus.Desc
lastResult *prometheus.Desc
lastResultStatus *prometheus.Desc
missedRuns *prometheus.Desc
state *prometheus.Desc
}
// TaskState ...
// https://docs.microsoft.com/en-us/windows/desktop/api/taskschd/ne-taskschd-task_state
type TaskState uint
type TaskResult uint
// TaskResult preserves the unsigned 32-bit representation of a result code,
// including HRESULTs returned as signed LONG values by Task Scheduler.
type TaskResult uint32
const (
TASK_STATE_UNKNOWN TaskState = iota
@@ -70,8 +73,17 @@ const (
)
const (
SCHED_S_SUCCESS TaskResult = 0x0
SCHED_S_TASK_HAS_NOT_RUN TaskResult = 0x00041303
SCHED_S_SUCCESS TaskResult = 0x0
SCHED_S_TASK_READY TaskResult = 0x00041300
SCHED_S_TASK_RUNNING TaskResult = 0x00041301
SCHED_S_TASK_DISABLED TaskResult = 0x00041302
SCHED_S_TASK_HAS_NOT_RUN TaskResult = 0x00041303
SCHED_S_TASK_NO_MORE_RUNS TaskResult = 0x00041304
SCHED_S_TASK_NOT_SCHEDULED TaskResult = 0x00041305
SCHED_S_TASK_TERMINATED TaskResult = 0x00041306
SCHED_S_TASK_NO_VALID_TRIGGERS TaskResult = 0x00041307
SCHED_S_EVENT_TRIGGER TaskResult = 0x00041308
SCHED_S_TASK_QUEUED TaskResult = 0x00041325
)
type ScheduledTask struct {
@@ -152,11 +164,19 @@ func (c *Collector) Close() error {
func (c *Collector) Build(_ *slog.Logger, _ *mi.Session) error {
c.lastResult = prometheus.NewDesc(
prometheus.BuildFQName(types.Namespace, Name, "last_result"),
"The result that was returned the last time the registered task was run",
"DEPRECATED: use windows_scheduled_task_last_result_status. "+
"1 if the last result code of the registered task is zero, 0 otherwise",
[]string{"task"},
nil,
)
c.lastResultStatus = prometheus.NewDesc(
prometheus.BuildFQName(types.Namespace, Name, "last_result_status"),
"The last result status of a scheduled task, 1 if the current status, 0 otherwise",
[]string{"task", "status"},
nil,
)
c.missedRuns = prometheus.NewDesc(
prometheus.BuildFQName(types.Namespace, Name, "missed_runs"),
"The number of times the registered task missed a scheduled run",
@@ -181,12 +201,24 @@ func (c *Collector) Collect(ch chan<- prometheus.Metric, _ time.Duration) error
//nolint:gochecknoglobals
var TASK_STATES = []string{"disabled", "queued", "ready", "running", "unknown"}
//nolint:gochecknoglobals
var TASK_RESULT_STATUSES = []string{
"success", "ready", "running", "disabled", "has_not_run", "no_more_runs",
"not_scheduled", "terminated", "no_valid_triggers", "event_trigger", "queued", "unknown",
}
func (c *Collector) collect(ch chan<- prometheus.Metric) error {
scheduledTasks, err := getScheduledTasks()
if err != nil {
return fmt.Errorf("get scheduled tasks: %w", err)
}
c.collectMetrics(ch, scheduledTasks)
return nil
}
func (c *Collector) collectMetrics(ch chan<- prometheus.Metric, scheduledTasks ScheduledTasks) {
for _, task := range scheduledTasks {
if c.config.TaskExclude.MatchString(task.Path) ||
!c.config.TaskInclude.MatchString(task.Path) {
@@ -209,6 +241,24 @@ func (c *Collector) collect(ch chan<- prometheus.Metric) error {
)
}
resultStatus := task.LastTaskResult.String()
for _, status := range TASK_RESULT_STATUSES {
var statusValue float64
if resultStatus == status {
statusValue = 1
}
ch <- prometheus.MustNewConstMetric(
c.lastResultStatus,
prometheus.GaugeValue,
statusValue,
task.Path,
status,
)
}
if task.LastTaskResult == SCHED_S_TASK_HAS_NOT_RUN {
continue
}
@@ -232,8 +282,6 @@ func (c *Collector) collect(ch chan<- prometheus.Metric) error {
task.Path,
)
}
return nil
}
const SCHEDULED_TASK_PROGRAM_ID = "Schedule.Service.1"
@@ -253,8 +301,7 @@ func getScheduledTasks() (ScheduledTasks, error) {
defer runtime.UnlockOSThread()
if err := ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED|ole.COINIT_DISABLE_OLE1DDE); err != nil {
var oleCode *ole.OleError
if errors.As(err, &oleCode) && oleCode.Code() != ole.S_OK && oleCode.Code() != S_FALSE {
if oleCode, ok := errors.AsType[*ole.OleError](err); ok && oleCode.Code() != ole.S_OK && oleCode.Code() != S_FALSE {
return nil, err
}
}
@@ -442,3 +489,32 @@ func (t TaskState) String() string {
return ""
}
}
func (t TaskResult) String() string {
switch t {
case SCHED_S_SUCCESS:
return "success"
case SCHED_S_TASK_READY:
return "ready"
case SCHED_S_TASK_RUNNING:
return "running"
case SCHED_S_TASK_DISABLED:
return "disabled"
case SCHED_S_TASK_HAS_NOT_RUN:
return "has_not_run"
case SCHED_S_TASK_NO_MORE_RUNS:
return "no_more_runs"
case SCHED_S_TASK_NOT_SCHEDULED:
return "not_scheduled"
case SCHED_S_TASK_TERMINATED:
return "terminated"
case SCHED_S_TASK_NO_VALID_TRIGGERS:
return "no_valid_triggers"
case SCHED_S_EVENT_TRIGGER:
return "event_trigger"
case SCHED_S_TASK_QUEUED:
return "queued"
default:
return "unknown"
}
}
@@ -0,0 +1,304 @@
// 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"
"testing"
"github.com/prometheus/client_golang/prometheus"
dto "github.com/prometheus/client_model/go"
"github.com/stretchr/testify/require"
)
func TestCollectMetrics(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
result int64
state TaskState
stateLabel string
status string
}{
{
name: "success", result: 0,
state: TASK_STATE_READY, stateLabel: "ready", status: "success",
},
{
name: "application error", result: 1,
state: TASK_STATE_READY, stateLabel: "ready", status: "unknown",
},
{
name: "ready", result: 0x41300,
state: TASK_STATE_READY, stateLabel: "ready", status: "ready",
},
{
name: "running", result: 0x41301,
state: TASK_STATE_RUNNING, stateLabel: "running", status: "running",
},
{
name: "disabled", result: 0x41302,
state: TASK_STATE_DISABLED, stateLabel: "disabled", status: "disabled",
},
{
name: "never run", result: 0x41303,
state: TASK_STATE_UNKNOWN, stateLabel: "unknown", status: "has_not_run",
},
{
name: "no more runs", result: 0x41304,
state: TASK_STATE_READY, stateLabel: "ready", status: "no_more_runs",
},
{
name: "not scheduled", result: 0x41305,
state: TASK_STATE_READY, stateLabel: "ready", status: "not_scheduled",
},
{
name: "terminated", result: 0x41306,
state: TASK_STATE_DISABLED, stateLabel: "disabled", status: "terminated",
},
{
name: "no valid triggers", result: 0x41307,
state: TASK_STATE_READY, stateLabel: "ready", status: "no_valid_triggers",
},
{
name: "event trigger", result: 0x41308,
state: TASK_STATE_READY, stateLabel: "ready", status: "event_trigger",
},
{
name: "queued", result: 0x41325,
state: TASK_STATE_QUEUED, stateLabel: "queued", status: "queued",
},
{
name: "signed HRESULT", result: -2147216629,
state: TASK_STATE_READY, stateLabel: "ready", status: "unknown",
},
{
name: "unsigned HRESULT", result: 0x8004130B,
state: TASK_STATE_READY, stateLabel: "ready", status: "unknown",
},
{
name: "unrecognized result", result: -559038737,
state: TASK_STATE_READY, stateLabel: "ready", status: "unknown",
},
{
name: "maximum code", result: -1,
state: TASK_STATE_READY, stateLabel: "ready", status: "unknown",
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
task := ScheduledTask{
Path: "/Test/Task",
State: tc.state,
MissedRunsCount: 3,
LastTaskResult: TaskResult(tc.result),
}
families := gatherTaskMetrics(t, nil, ScheduledTasks{task})
states := families["windows_scheduled_task_state"]
require.NotNil(t, states)
require.Equal(t, dto.MetricType_GAUGE, states.GetType())
require.Len(t, states.GetMetric(), 5)
expectedStates := map[string]float64{"disabled": 0, "queued": 0, "ready": 0, "running": 0, "unknown": 0}
expectedStates[tc.stateLabel] = 1
for _, metric := range states.GetMetric() {
require.Len(t, metric.GetLabel(), 2)
require.Equal(t, "state", metric.GetLabel()[0].GetName())
require.Equal(t, "task", metric.GetLabel()[1].GetName())
require.Equal(t, task.Path, metric.GetLabel()[1].GetValue())
state := metric.GetLabel()[0].GetValue()
want, ok := expectedStates[state]
require.True(t, ok, "unexpected or duplicate state %s", state)
require.InDelta(t, want, metric.GetGauge().GetValue(), 0)
delete(expectedStates, state)
}
require.Empty(t, expectedStates)
statuses := families["windows_scheduled_task_last_result_status"]
require.NotNil(t, statuses)
require.Equal(t, dto.MetricType_GAUGE, statuses.GetType())
require.Len(t, statuses.GetMetric(), 12)
expectedStatuses := map[string]float64{
"success": 0, "ready": 0, "running": 0, "disabled": 0,
"has_not_run": 0, "no_more_runs": 0, "not_scheduled": 0, "terminated": 0,
"no_valid_triggers": 0, "event_trigger": 0, "queued": 0, "unknown": 0,
}
expectedStatuses[tc.status] = 1
for _, metric := range statuses.GetMetric() {
require.Len(t, metric.GetLabel(), 2)
require.Equal(t, "status", metric.GetLabel()[0].GetName())
require.Equal(t, "task", metric.GetLabel()[1].GetName())
require.Equal(t, task.Path, metric.GetLabel()[1].GetValue())
status := metric.GetLabel()[0].GetValue()
want, ok := expectedStatuses[status]
require.True(t, ok, "unexpected or duplicate status %s", status)
require.InDelta(t, want, metric.GetGauge().GetValue(), 0)
delete(expectedStatuses, status)
}
require.Empty(t, expectedStatuses)
if tc.result == 0x41303 {
require.Len(t, families, 2)
require.NotContains(t, families, "windows_scheduled_task_last_result")
require.NotContains(t, families, "windows_scheduled_task_missed_runs")
return
}
require.Len(t, families, 4)
lastResult := families["windows_scheduled_task_last_result"]
require.NotNil(t, lastResult)
require.Len(t, lastResult.GetMetric(), 1)
wantSuccess := 0.0
if tc.result == 0 {
wantSuccess = 1
}
require.InDelta(t, wantSuccess, lastResult.GetMetric()[0].GetGauge().GetValue(), 0)
missedRuns := families["windows_scheduled_task_missed_runs"]
require.NotNil(t, missedRuns)
require.Len(t, missedRuns.GetMetric(), 1)
require.InDelta(t, task.MissedRunsCount, missedRuns.GetMetric()[0].GetGauge().GetValue(), 0)
})
}
}
func TestCollectMetricsFilters(t *testing.T) {
t.Parallel()
tasks := ScheduledTasks{
{Path: "/Included/Task", State: TASK_STATE_READY},
{Path: "/Included/Excluded", State: TASK_STATE_READY},
{Path: "/Other/Task", State: TASK_STATE_READY},
}
for _, tc := range []struct {
name string
config *Config
tasks ScheduledTasks
paths []string
}{
{name: "empty", tasks: ScheduledTasks{}, paths: []string{}},
{name: "defaults", tasks: tasks, paths: []string{"/Included/Task", "/Included/Excluded", "/Other/Task"}},
{
name: "include",
config: &Config{TaskInclude: regexp.MustCompile(`^/Included/`)},
tasks: tasks,
paths: []string{"/Included/Task", "/Included/Excluded"},
},
{
name: "exclude takes precedence",
config: &Config{
TaskInclude: regexp.MustCompile(`^/Included/`),
TaskExclude: regexp.MustCompile(`Excluded$`),
},
tasks: tasks,
paths: []string{"/Included/Task"},
},
{
name: "all excluded",
config: &Config{TaskExclude: regexp.MustCompile(`.*`)},
tasks: tasks,
paths: []string{},
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
families := gatherTaskMetrics(t, tc.config, tc.tasks)
if len(tc.paths) == 0 {
require.Empty(t, families)
return
}
require.Len(t, families, 4)
for _, family := range families {
paths := make([]string, 0, len(family.GetMetric()))
for _, metric := range family.GetMetric() {
for _, label := range metric.GetLabel() {
if label.GetName() == "task" {
paths = append(paths, label.GetValue())
}
}
}
samplesPerTask := 1
if family.GetName() == "windows_scheduled_task_state" {
samplesPerTask = 5
}
if family.GetName() == "windows_scheduled_task_last_result_status" {
samplesPerTask = 12
}
expected := make([]string, 0, samplesPerTask*len(tc.paths))
for range samplesPerTask {
expected = append(expected, tc.paths...)
}
require.ElementsMatch(t, expected, paths, "%s", family.GetName())
}
})
}
}
func gatherTaskMetrics(t *testing.T, config *Config, tasks ScheduledTasks) map[string]*dto.MetricFamily {
t.Helper()
c := New(config)
require.NoError(t, c.Build(nil, nil))
registry := prometheus.NewPedanticRegistry()
require.NoError(t, registry.Register(taskMetricCollector{collector: c, tasks: tasks}))
families, err := registry.Gather()
require.NoError(t, err)
result := make(map[string]*dto.MetricFamily, len(families))
for _, family := range families {
result[family.GetName()] = family
}
return result
}
type taskMetricCollector struct {
collector *Collector
tasks ScheduledTasks
}
func (c taskMetricCollector) Describe(ch chan<- *prometheus.Desc) {
prometheus.DescribeByCollect(c, ch)
}
func (c taskMetricCollector) Collect(ch chan<- prometheus.Metric) {
c.collector.collectMetrics(ch, c.tasks)
}