printer: read printers and jobs from the spooler API (#2577)

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-09 09:36:59 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent cb5b4cb0bf
commit 7c200078ef
7 changed files with 774 additions and 138 deletions
+153 -116
View File
@@ -22,17 +22,28 @@ import (
"fmt"
"log/slog"
"regexp"
"strings"
"time"
"github.com/alecthomas/kingpin/v2"
"github.com/prometheus-community/windows_exporter/internal/headers/winspool"
"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"
)
const Name = "printer"
// Win32_Printer PrinterStatus values.
// https://learn.microsoft.com/en-us/windows/win32/cimwin32prov/win32-printer
const (
printerStatusOther uint16 = 1
printerStatusUnknown uint16 = 2
printerStatusIdle uint16 = 3
printerStatusPrinting uint16 = 4
printerStatusWarmup uint16 = 5
)
// printerStatusMap source: https://learn.microsoft.com/en-us/windows/win32/cimwin32prov/win32-printer#:~:text=Power%20Save-,PrinterStatus,Offline%20(7),-PrintJobDataType
//
//nolint:gochecknoglobals
@@ -58,10 +69,7 @@ var ConfigDefaults = Config{
}
type Collector struct {
config Config
miSession *mi.Session
miQueryPrinterJobs mi.Query
miQueryPrinter mi.Query
config Config
printerStatus *prometheus.Desc
printerJobStatus *prometheus.Desc
@@ -128,7 +136,7 @@ func (c *Collector) Close() error {
return nil
}
func (c *Collector) Build(_ *slog.Logger, miSession *mi.Session) error {
func (c *Collector) Build(_ *slog.Logger, _ *mi.Session) error {
c.printerJobStatus = prometheus.NewDesc(
prometheus.BuildFQName(types.Namespace, Name, "job_status"),
"A counter of printer jobs by status",
@@ -148,86 +156,49 @@ func (c *Collector) Build(_ *slog.Logger, miSession *mi.Session) error {
nil,
)
if miSession == nil {
return errors.New("miSession is nil")
}
miQuery, err := mi.NewQuery("SELECT Name, Default, PrinterStatus, JobCountSinceLastReset FROM win32_Printer")
if err != nil {
return fmt.Errorf("failed to create WMI query: %w", err)
}
c.miQueryPrinter = miQuery
miQuery, err = mi.NewQuery("SELECT Name, Status FROM win32_PrintJob")
if err != nil {
return fmt.Errorf("failed to create WMI query: %w", err)
}
c.miQueryPrinterJobs = miQuery
c.miSession = miSession
return nil
}
func (c *Collector) GetName() string { return Name }
type wmiPrinter struct {
Name string `mi:"Name"`
Default bool `mi:"Default"`
PrinterStatus uint16 `mi:"PrinterStatus"`
JobCountSinceLastReset uint32 `mi:"JobCountSinceLastReset"`
}
// Collect reads printers and jobs from the print spooler.
// Win32_Printer is not used because the CIMWin32 provider calls DeviceCapabilities
// for every printer, which blocks for about a minute on an unreachable network printer.
func (c *Collector) Collect(ch chan<- prometheus.Metric, _ time.Duration) error {
// CIMWin32 enumerates the same printers for Win32_Printer.
printers, err := winspool.EnumPrinters(winspool.PRINTER_ENUM_LOCAL | winspool.PRINTER_ENUM_CONNECTIONS)
if err != nil {
return fmt.Errorf("failed to enumerate printers: %w", err)
}
type wmiPrintJob struct {
Name string `mi:"Name"`
Status string `mi:"Status"`
}
// minQueryTimeout keeps the job query bounded once the scrape budget is spent.
// MI counts timeouts in whole milliseconds, and a zero timeout means no timeout at all.
const minQueryTimeout = time.Millisecond
func (c *Collector) Collect(ch chan<- prometheus.Metric, maxScrapeDuration time.Duration) error {
var errs []error
deadline := time.Now().Add(maxScrapeDuration)
// Win32_Printer calls DeviceCapabilities for every printer, whichever properties are selected.
// For an IPP printer that is offline, this blocks until the network requests time out,
// which can take close to a minute. Bound the query by the scrape timeout, so that a hung call
// fails with the native MI timeout instead of blocking the collector for later scrapes.
if err := c.collectPrinterStatus(ch, maxScrapeDuration); err != nil {
errs = append(errs, fmt.Errorf("failed to collect printer status metrics: %w", err))
}
jobQueryTimeout := maxScrapeDuration
if maxScrapeDuration > 0 {
jobQueryTimeout = max(time.Until(deadline), minQueryTimeout)
}
if err := c.collectPrinterJobStatus(ch, jobQueryTimeout); err != nil {
errs = append(errs, fmt.Errorf("failed to collect printer job status metrics: %w", err))
}
return errors.Join(errs...)
}
func (c *Collector) collectPrinterStatus(ch chan<- prometheus.Metric, maxScrapeDuration time.Duration) error {
var printers []wmiPrinter
if err := c.miSession.Query(&printers, mi.NamespaceRootCIMv2, c.miQueryPrinter, maxScrapeDuration); err != nil {
return fmt.Errorf("WMI query failed: %w", err)
}
for _, printer := range printers {
if c.config.PrinterExclude.MatchString(printer.Name) ||
!c.config.PrinterInclude.MatchString(printer.Name) {
continue
}
for printerStatus, printerStatusName := range printerStatusMap {
// Win32_Printer JobCountSinceLastReset is PRINTER_INFO_2.cJobs, the number of queued jobs.
ch <- prometheus.MustNewConstMetric(
c.printerJobCount,
prometheus.CounterValue,
float64(printer.Jobs),
printer.Name,
)
jobs, err := printerJobs(printer)
if err != nil {
errs = append(errs, fmt.Errorf("failed to enumerate jobs of printer %s: %w", printer.Name, err))
continue
}
currentStatus := printerStatus(printer.Status, jobs)
for status, statusName := range printerStatusMap {
isCurrentStatus := 0.0
if printerStatus == printer.PrinterStatus {
if status == currentStatus {
isCurrentStatus = 1.0
}
@@ -236,62 +207,128 @@ func (c *Collector) collectPrinterStatus(ch chan<- prometheus.Metric, maxScrapeD
prometheus.GaugeValue,
isCurrentStatus,
printer.Name,
printerStatusName,
statusName,
)
}
ch <- prometheus.MustNewConstMetric(
c.printerJobCount,
prometheus.CounterValue,
float64(printer.JobCountSinceLastReset),
printer.Name,
)
for status, count := range groupJobsByStatus(jobs) {
ch <- prometheus.MustNewConstMetric(
c.printerJobStatus,
prometheus.GaugeValue,
float64(count),
printer.Name,
status,
)
}
}
return nil
return errors.Join(errs...)
}
func (c *Collector) collectPrinterJobStatus(ch chan<- prometheus.Metric, maxScrapeDuration time.Duration) error {
var printJobs []wmiPrintJob
if err := c.miSession.Query(&printJobs, mi.NamespaceRootCIMv2, c.miQueryPrinterJobs, maxScrapeDuration); err != nil {
return fmt.Errorf("WMI query failed: %w", err)
// printerJobs returns the jobs of the printer in queue order.
func printerJobs(printer winspool.Printer) (_ []winspool.Job, err error) {
// Skip OpenPrinter for an empty queue. For a printer connection, it can contact the print server.
if printer.Jobs == 0 {
return nil, nil
}
groupedPrintJobs := c.groupPrintJobs(printJobs)
for group, count := range groupedPrintJobs {
ch <- prometheus.MustNewConstMetric(
c.printerJobStatus,
prometheus.GaugeValue,
float64(count),
group.printerName,
group.status,
)
}
return nil
}
type PrintJobStatusGroup struct {
printerName string
status string
}
func (c *Collector) groupPrintJobs(printJobs []wmiPrintJob) map[PrintJobStatusGroup]int {
groupedPrintJobs := make(map[PrintJobStatusGroup]int)
for _, printJob := range printJobs {
printerName, _, _ := strings.Cut(printJob.Name, ",")
if c.config.PrinterExclude.MatchString(printerName) ||
!c.config.PrinterInclude.MatchString(printerName) {
continue
handle, err := winspool.OpenPrinter(printer.Name)
if err != nil {
// The printer was deleted after EnumPrinters.
if errors.Is(err, windows.ERROR_INVALID_PRINTER_NAME) {
return nil, nil
}
groupedPrintJobs[PrintJobStatusGroup{
printerName: printerName,
status: printJob.Status,
}]++
return nil, err
}
return groupedPrintJobs
defer func() {
err = errors.Join(err, winspool.ClosePrinter(handle))
}()
return winspool.EnumJobs(handle)
}
// printerStatus returns the Win32_Printer PrinterStatus value for PRINTER_INFO_2.Status,
// mapped the same way as the CIMWin32 provider (cimwin32.dll).
//
// CIMWin32 compares the whole Status value with single PRINTER_STATUS_* flags.
// For 0 and for combinations of flags, it uses the first job in the queue instead.
// It never reports Stopped Printing (6) or Offline (7); an offline printer is Other (1).
func printerStatus(status uint32, jobs []winspool.Job) uint16 {
switch status {
case winspool.PRINTER_STATUS_PAUSED,
winspool.PRINTER_STATUS_ERROR,
winspool.PRINTER_STATUS_PENDING_DELETION,
winspool.PRINTER_STATUS_PAPER_JAM,
winspool.PRINTER_STATUS_PAPER_OUT,
winspool.PRINTER_STATUS_MANUAL_FEED,
winspool.PRINTER_STATUS_PAPER_PROBLEM,
winspool.PRINTER_STATUS_OFFLINE,
winspool.PRINTER_STATUS_OUTPUT_BIN_FULL,
winspool.PRINTER_STATUS_TONER_LOW,
winspool.PRINTER_STATUS_NO_TONER,
winspool.PRINTER_STATUS_PAGE_PUNT,
winspool.PRINTER_STATUS_USER_INTERVENTION,
winspool.PRINTER_STATUS_OUT_OF_MEMORY,
winspool.PRINTER_STATUS_DOOR_OPEN:
return printerStatusOther
case winspool.PRINTER_STATUS_IO_ACTIVE,
winspool.PRINTER_STATUS_BUSY,
winspool.PRINTER_STATUS_PRINTING,
winspool.PRINTER_STATUS_PROCESSING:
return printerStatusPrinting
case winspool.PRINTER_STATUS_NOT_AVAILABLE:
return printerStatusUnknown
case winspool.PRINTER_STATUS_WAITING,
winspool.PRINTER_STATUS_SERVER_UNKNOWN,
winspool.PRINTER_STATUS_POWER_SAVE:
return printerStatusIdle
case winspool.PRINTER_STATUS_INITIALIZING,
winspool.PRINTER_STATUS_WARMING_UP:
return printerStatusWarmup
}
if len(jobs) == 0 {
return printerStatusIdle
}
job := jobs[0]
switch {
case job.StatusText != "":
return printerStatusUnknown
case job.Status&(winspool.JOB_STATUS_PAUSED|winspool.JOB_STATUS_ERROR|winspool.JOB_STATUS_DELETING|
winspool.JOB_STATUS_OFFLINE|winspool.JOB_STATUS_PAPEROUT|winspool.JOB_STATUS_PRINTED) != 0:
return printerStatusOther
case job.Status&(winspool.JOB_STATUS_SPOOLING|winspool.JOB_STATUS_PRINTING) != 0:
return printerStatusPrinting
default:
return printerStatusUnknown
}
}
// jobStatus returns the Win32_PrintJob Status value for JOB_INFO_1.Status,
// mapped the same way as the CIMWin32 provider (cimwin32.dll).
func jobStatus(status uint32) string {
switch {
case status&winspool.JOB_STATUS_ERROR != 0:
return "Error"
case status&(winspool.JOB_STATUS_PAUSED|winspool.JOB_STATUS_OFFLINE|winspool.JOB_STATUS_PAPEROUT) != 0:
return "Degraded"
case status&(winspool.JOB_STATUS_DELETING|winspool.JOB_STATUS_SPOOLING|winspool.JOB_STATUS_PRINTING|winspool.JOB_STATUS_PRINTED) != 0:
return "OK"
default:
return "UNKNOWN"
}
}
func groupJobsByStatus(jobs []winspool.Job) map[string]int {
groupedJobs := make(map[string]int)
for _, job := range jobs {
groupedJobs[jobStatus(job.Status)]++
}
return groupedJobs
}
@@ -0,0 +1,144 @@
// 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 printer
import (
"fmt"
"testing"
"github.com/prometheus-community/windows_exporter/internal/headers/winspool"
"github.com/stretchr/testify/require"
)
// The expected values follow the PrinterStatus switch in cimwin32.dll (10.0.26100).
func TestPrinterStatus(t *testing.T) {
t.Parallel()
for status, want := range map[uint32]uint16{
winspool.PRINTER_STATUS_PAUSED: printerStatusOther,
winspool.PRINTER_STATUS_ERROR: printerStatusOther,
winspool.PRINTER_STATUS_PENDING_DELETION: printerStatusOther,
winspool.PRINTER_STATUS_PAPER_JAM: printerStatusOther,
winspool.PRINTER_STATUS_PAPER_OUT: printerStatusOther,
winspool.PRINTER_STATUS_MANUAL_FEED: printerStatusOther,
winspool.PRINTER_STATUS_PAPER_PROBLEM: printerStatusOther,
winspool.PRINTER_STATUS_OFFLINE: printerStatusOther,
winspool.PRINTER_STATUS_IO_ACTIVE: printerStatusPrinting,
winspool.PRINTER_STATUS_BUSY: printerStatusPrinting,
winspool.PRINTER_STATUS_PRINTING: printerStatusPrinting,
winspool.PRINTER_STATUS_OUTPUT_BIN_FULL: printerStatusOther,
winspool.PRINTER_STATUS_NOT_AVAILABLE: printerStatusUnknown,
winspool.PRINTER_STATUS_WAITING: printerStatusIdle,
winspool.PRINTER_STATUS_PROCESSING: printerStatusPrinting,
winspool.PRINTER_STATUS_INITIALIZING: printerStatusWarmup,
winspool.PRINTER_STATUS_WARMING_UP: printerStatusWarmup,
winspool.PRINTER_STATUS_TONER_LOW: printerStatusOther,
winspool.PRINTER_STATUS_NO_TONER: printerStatusOther,
winspool.PRINTER_STATUS_PAGE_PUNT: printerStatusOther,
winspool.PRINTER_STATUS_USER_INTERVENTION: printerStatusOther,
winspool.PRINTER_STATUS_OUT_OF_MEMORY: printerStatusOther,
winspool.PRINTER_STATUS_DOOR_OPEN: printerStatusOther,
winspool.PRINTER_STATUS_SERVER_UNKNOWN: printerStatusIdle,
winspool.PRINTER_STATUS_POWER_SAVE: printerStatusIdle,
} {
t.Run(fmt.Sprintf("0x%08X", status), func(t *testing.T) {
t.Parallel()
// A single flag ignores the jobs.
require.Equal(t, want, printerStatus(status, nil))
require.Equal(t, want, printerStatus(status, []winspool.Job{{Status: winspool.JOB_STATUS_ERROR}}))
})
}
}
func TestPrinterStatusFromJobs(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
status uint32
jobs []winspool.Job
want uint16
}{
{name: "empty queue", jobs: nil, want: printerStatusIdle},
{name: "spooling", jobs: []winspool.Job{{Status: winspool.JOB_STATUS_SPOOLING}}, want: printerStatusPrinting},
{name: "printing", jobs: []winspool.Job{{Status: winspool.JOB_STATUS_PRINTING}}, want: printerStatusPrinting},
{name: "paused", jobs: []winspool.Job{{Status: winspool.JOB_STATUS_PAUSED | winspool.JOB_STATUS_SPOOLING}}, want: printerStatusOther},
{name: "error", jobs: []winspool.Job{{Status: winspool.JOB_STATUS_ERROR | winspool.JOB_STATUS_PRINTING}}, want: printerStatusOther},
{name: "printed", jobs: []winspool.Job{{Status: winspool.JOB_STATUS_PRINTED}}, want: printerStatusOther},
{name: "no job flags", jobs: []winspool.Job{{Status: 0}}, want: printerStatusUnknown},
{name: "blocked only", jobs: []winspool.Job{{Status: winspool.JOB_STATUS_BLOCKED_DEVQ}}, want: printerStatusUnknown},
{name: "status text", jobs: []winspool.Job{{Status: winspool.JOB_STATUS_PRINTING, StatusText: "Toner low"}}, want: printerStatusUnknown},
{
name: "only the first job counts",
jobs: []winspool.Job{{Status: winspool.JOB_STATUS_SPOOLING}, {Status: winspool.JOB_STATUS_ERROR}},
want: printerStatusPrinting,
},
{
name: "combined printer flags use the jobs",
status: winspool.PRINTER_STATUS_PAUSED | winspool.PRINTER_STATUS_OFFLINE,
jobs: nil,
want: printerStatusIdle,
},
{
name: "server offline uses the jobs",
status: winspool.PRINTER_STATUS_SERVER_OFFLINE,
jobs: []winspool.Job{{Status: winspool.JOB_STATUS_PRINTING}},
want: printerStatusPrinting,
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
require.Equal(t, tc.want, printerStatus(tc.status, tc.jobs))
})
}
}
// The expected values follow the Win32_PrintJob Status logic in cimwin32.dll (10.0.26100).
func TestJobStatus(t *testing.T) {
t.Parallel()
for status, want := range map[uint32]string{
0: "UNKNOWN",
winspool.JOB_STATUS_BLOCKED_DEVQ: "UNKNOWN",
winspool.JOB_STATUS_SPOOLING: "OK",
winspool.JOB_STATUS_PRINTING: "OK",
winspool.JOB_STATUS_DELETING: "OK",
winspool.JOB_STATUS_PRINTED: "OK",
winspool.JOB_STATUS_PAUSED: "Degraded",
winspool.JOB_STATUS_OFFLINE: "Degraded",
winspool.JOB_STATUS_PAPEROUT: "Degraded",
winspool.JOB_STATUS_ERROR: "Error",
winspool.JOB_STATUS_PAUSED | winspool.JOB_STATUS_SPOOLING: "Degraded",
winspool.JOB_STATUS_ERROR | winspool.JOB_STATUS_PAUSED: "Error",
} {
require.Equal(t, want, jobStatus(status), "status 0x%08X", status)
}
}
func TestGroupJobsByStatus(t *testing.T) {
t.Parallel()
require.Equal(t, map[string]int{"OK": 2, "Error": 1}, groupJobsByStatus([]winspool.Job{
{Status: winspool.JOB_STATUS_SPOOLING},
{Status: winspool.JOB_STATUS_PRINTING},
{Status: winspool.JOB_STATUS_ERROR | winspool.JOB_STATUS_PRINTING},
}))
require.Empty(t, groupJobsByStatus(nil))
}
+6 -17
View File
@@ -24,7 +24,6 @@ import (
"github.com/alecthomas/kingpin/v2"
"github.com/prometheus-community/windows_exporter/internal/collector/printer"
"github.com/prometheus-community/windows_exporter/internal/mi"
"github.com/prometheus-community/windows_exporter/internal/utils/testutils"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/assert"
@@ -45,28 +44,18 @@ func TestCollector(t *testing.T) {
testutils.RequireFixtureMetric(t, metrics, printer.Name, "windows_printer_job_count", prometheus.Labels{"printer": "CIPrinter"})
}
// TestCollectorScrapeTimeout checks that the Win32_Printer query is bounded by the scrape timeout.
// Win32_Printer can block for close to a minute on an offline IPP printer; without a timeout,
// that call keeps the collector busy across scrapes.
func TestCollectorScrapeTimeout(t *testing.T) {
miApp, err := mi.ApplicationInitialize()
require.NoError(t, err)
t.Cleanup(func() { assert.NoError(t, miApp.Close()) })
miSession, err := miApp.NewSession(nil)
require.NoError(t, err)
t.Cleanup(func() { assert.NoError(t, miSession.Close()) })
// TestCollectorWithoutMISession checks that the collector does not depend on WMI.
// Win32_Printer can block for close to a minute on an offline IPP printer.
func TestCollectorWithoutMISession(t *testing.T) {
t.Parallel()
c := printer.New(nil)
t.Cleanup(func() { assert.NoError(t, c.Close()) })
require.NoError(t, c.Build(slog.New(slog.DiscardHandler), miSession))
require.NoError(t, c.Build(slog.New(slog.DiscardHandler), nil))
ch := make(chan prometheus.Metric, 1000)
// 1ms is the smallest timeout MI enforces, and well below the time Win32_Printer needs.
err = c.Collect(ch, time.Millisecond)
require.ErrorIs(t, err, mi.MI_RESULT_INVALID_OPERATION_TIMEOUT)
require.ErrorContains(t, err, "failed to collect printer status metrics")
require.NoError(t, c.Collect(ch, time.Second))
}