Monitor and report on print activity (Preview)

After you set up Universal Print Logs and Alerting, your printer health and print job telemetry flows into your own Azure Monitor Log Analytics workspace. This article provides a ready-to-use set of Kusto (KQL) queries — for spotting printers that need attention and for reporting on print volume — and then shows you how to turn any of them into a dashboard, an automated alert, or an Excel or Power BI report.

For a description of every table and column these queries use, see the Logs and Alerting data reference.

Prerequisites

  • The Logs and Alerting feature is enabled and sending data to a Log Analytics workspace. See Set up Universal Print Logs and Alerting.
  • At least Reader (or Log Analytics Reader) on the workspace to run queries. To create alert rules you also need permission to create alert rules and action groups in the resource group (for example, Monitoring Contributor).
  • Some print activity in the query window so the tables aren't empty.

How printers are flagged

Queries 1 and 3 flag a printer when any one of four signals crosses a threshold within the lookback window. (Query 2 doesn't flag — it's a drill-down into a printer that Query 1 already flagged.) Each threshold is a tunable parameter at the top of the query — adjust them for your tenant's print volume.

Signal Flags when… What it indicates
Cancelled jobs The number of canceled jobs reaches the threshold (default 3). Users or the system are giving up on jobs.
Unique cancelling users The number of distinct users who hit a cancellation reaches the threshold (default 2). A problem affecting many people, not one user retrying.
Jobs waiting for pickup The number of jobs whose most recent lifecycle event is still readyForPrinter (not yet picked up) reaches the threshold (default 3). A pickup backlog — the printer may be offline, jammed, or out of paper.
Aborted jobs The number of aborted jobs reaches the threshold (default 2). The printer or service ended jobs mid-flight (device-side failure).

Printer health status (state, error reasons, accepting-jobs) is shown alongside a flagged printer to help you troubleshoot, but health alone never flags a printer — attention is driven purely by job outcomes.

Note

Country and city are only available on the printer health table (LocationInfo). The health lookup uses the full table (not just the lookback window), so a printer's most recent location and state appear even if its last health report predates the window. A printer that has never reported health shows Country == "(no location)".

Queries

Run any of these in your workspace Logs blade. For an introduction to running log queries, see Log queries in Azure Monitor. After a query returns what you need, see Use the queries to turn it into a dashboard, alert, or report.

Query Answers Typical use
Query 1 Which printers need attention right now? A fleet dashboard.
Query 2 Which specific jobs and users are stuck on a printer? Troubleshooting a flagged printer.
Query 3 Notify me when a printer in a given region needs attention. An alert rule.
Query 4 How much did each printer print? Capacity and placement reports.
Query 5 How much did each user print? Chargeback and quota reports.

Query 1 — Printers that need attention

Returns one row per printer that had job activity in the window — both flagged and healthy — sorted so printers needing attention appear first. (A printer that reported health but processed no jobs in the window doesn't appear; attention is driven by job outcomes.)

// Tunable thresholds — adjust for your tenant's volume and the lookback window.
let cancelledJobThreshold        = 3;   // flag if cancelled jobs        >= this
let uniqueCancelledUserThreshold = 2;   // flag if unique cancelling users >= this
let pendingPickupThreshold       = 3;   // flag if jobs waiting for pickup >= this
let abortedJobThreshold          = 2;   // flag if aborted jobs          >= this
let lookback = 6h;
// Most recent printer-health snapshot per printer (state, errors, location),
// taken from the full table so metadata is available even when the printer's
// last health report predates the job lookback window.
let printerHealth =
    UniversalPrintPrinterHealth_CL
    | extend PrinterState    = tostring(PrinterStatusDetails.PrinterState),
             PrinterStateMsg = tostring(PrinterStatusDetails.PrinterStateMessage),
             IsAcceptingJobs = tobool(PrinterStatusDetails.IsAcceptingJobs),
             ErrorCount      = array_length(PrinterStatusDetails.PrinterStateReasonErrors)
    | extend Country = tostring(LocationInfo.CountryOrRegion),
             City    = tostring(LocationInfo.Locality)
    | extend IsBadHealth = (PrinterState == "stopped")
                           or (IsAcceptingJobs == false)
                           or (ErrorCount > 0)
    | summarize
            arg_max(TimeGenerated, PrinterState, PrinterStateMsg,
                    IsAcceptingJobs, ErrorCount, IsBadHealth, Country, City),
            LastBadHealthTime = maxif(TimeGenerated, IsBadHealth)
        by PrinterId
    | extend LatestHealth = iff(IsBadHealth, "Bad", "Healthy")
    | extend HealthRecovery = case(
            isnull(LastBadHealthTime), "No bad health on record",
            IsBadHealth, strcat("Still bad (", PrinterState, ") since ",
                                format_datetime(LastBadHealthTime, 'yyyy-MM-dd HH:mm')),
            strcat("Recovered ~", datetime_diff('minute', now(), LastBadHealthTime),
                   " min ago (last bad ", format_datetime(LastBadHealthTime, 'yyyy-MM-dd HH:mm'), ")"))
    | project PrinterId, LatestHealth, LatestHealthState = PrinterState,
              IsAcceptingJobs, LatestHealthMessage = PrinterStateMsg,
              Country, City, LastHealthReportTime = TimeGenerated,
              LastBadHealthTime, HealthRecovery;
UniversalPrintJob_CL
| where TimeGenerated >= ago(lookback)
// Collapse all lifecycle-event rows to the latest snapshot per job.
| summarize arg_max(TimeGenerated, JobLifecycleEvent, JobState, CreatedBy,
                    PrinterId, PrinterName, PrinterShareName),
            ReadyForPrinterDateTime = max(ReadyForPrinterDateTime)
    by JobId
| extend Printer = coalesce(PrinterName, PrinterShareName, "(unassigned)")
| extend Outcome = case(
        JobState == "completed",                "Completed",
        JobState == "canceled",                 "Cancelled",
        JobState == "aborted",                  "Aborted",
        JobLifecycleEvent == "readyForPrinter", "WaitingForPickup",
                                                "InProgress")
| extend WaitingMinutes = iff(Outcome == "WaitingForPickup",
                              datetime_diff('minute', now(), ReadyForPrinterDateTime), long(null))
| summarize
        WaitingForPickup     = countif(Outcome == "WaitingForPickup"),
        Completed            = countif(Outcome == "Completed"),
        Cancelled            = countif(Outcome == "Cancelled"),
        Aborted              = countif(Outcome == "Aborted"),
        InProgress           = countif(Outcome == "InProgress"),
        TotalJobs            = count(),
        UniqueUsers          = dcount(CreatedBy),
        UniqueCancelledUsers = dcountif(CreatedBy, Outcome == "Cancelled"),
        LongestWaitMin       = maxif(WaitingMinutes, Outcome == "WaitingForPickup"),
        LastActivity         = max(TimeGenerated)
    by Printer, PrinterId
| join kind=leftouter printerHealth on PrinterId
| extend NeedsAttention =
        Cancelled            >= cancelledJobThreshold
        or UniqueCancelledUsers >= uniqueCancelledUserThreshold
        or WaitingForPickup  >= pendingPickupThreshold
        or Aborted           >= abortedJobThreshold
| extend AttentionReason = iff(
    NeedsAttention,
    strcat_array(array_concat(
        iff(Cancelled >= cancelledJobThreshold,
            pack_array(strcat(Cancelled, " cancelled jobs")), dynamic([])),
        iff(UniqueCancelledUsers >= uniqueCancelledUserThreshold,
            pack_array(strcat(UniqueCancelledUsers, " unique users with cancelled jobs")), dynamic([])),
        iff(WaitingForPickup >= pendingPickupThreshold,
            pack_array(strcat(WaitingForPickup, " jobs waiting for pickup")), dynamic([])),
        iff(Aborted >= abortedJobThreshold,
            pack_array(strcat(Aborted, " aborted jobs")), dynamic([]))
    ), " | "),
    "Healthy")
| project Printer, PrinterId, NeedsAttention, AttentionReason,
          Country = coalesce(Country, "(no location)"), City,
          LatestHealth = coalesce(LatestHealth, "(no health data)"),
          HealthRecovery = coalesce(HealthRecovery, "No health data on record"),
          LatestHealthState, IsAcceptingJobs, LatestHealthMessage,
          LastHealthReportTime, LastBadHealthTime,
          WaitingForPickup, Completed, Cancelled, Aborted, InProgress,
          UniqueUsers, UniqueCancelledUsers, TotalJobs, LongestWaitMin, LastActivity
| sort by NeedsAttention desc, Cancelled desc, Aborted desc, WaitingForPickup desc, UniqueCancelledUsers desc

The AttentionReason column lists every signal that fired (for example, 5 cancelled jobs | 3 unique users with cancelled jobs), so you can see why a printer is flagged at a glance. Healthy printers read Healthy.

Tip

If you get no rows or nothing is flagged, your tenant may simply be below the thresholds for the window. Widen lookback (for example, 7d) or lower the threshold values to see output.

Note

The lookback variable bounds only the job activity (cancellations, pickups, and so on). The printer-health lookup deliberately omits a time filter so each printer's most recent location and state are always available. Because the query already filters UniversalPrintJob_CL on TimeGenerated, the portal's time-range picker shows Set in query and doesn't clip the health table — leave it as is.

Query 2 — Drill into a flagged printer

Query 1 gives you counts. When a printer is flagged for jobs waiting for pickup, run Query 2 to list the individual stuck jobs, who submitted them, and how long each has waited. Set targetPrinter to the printer name or share name shown in Query 1's Printer column.

let lookback = 6h;
let targetPrinter = "<printer name or share name>";   // from Query 1's Printer column
UniversalPrintJob_CL
| where TimeGenerated >= ago(lookback)
| summarize arg_max(TimeGenerated, JobLifecycleEvent, JobState, CreatedBy,
                    PrinterName, PrinterShareName),
            ReadyForPrinterDateTime = max(ReadyForPrinterDateTime)
    by JobId
| extend Printer = coalesce(PrinterName, PrinterShareName, "(unassigned)")
| where Printer =~ targetPrinter
    and JobLifecycleEvent == "readyForPrinter"
    and JobState !in ("completed", "canceled", "aborted")
| extend WaitingMinutes = datetime_diff('minute', now(), ReadyForPrinterDateTime)
| project Printer, JobId, CreatedBy, JobState, ReadyForPrinterDateTime, WaitingMinutes
| sort by WaitingMinutes desc

Query 3 — Region-filtered (alert-ready)

Query 3 is a self-contained, region-filtered version of Query 1 that's ready to drop into an Azure Monitor scheduled-query alert rule (see Create an alert). It returns only flagged printers in the region you specify, so the alert can simply fire when the result has rows.

Set targetCountry to match the value stored in your printers' location (LocationInfo.CountryOrRegion). The comparison is case-insensitive. To discover the exact values your tenant uses, run this first:

UniversalPrintPrinterHealth_CL
| summarize Printers = dcount(PrinterId) by Country = tostring(LocationInfo.CountryOrRegion)
| order by Printers desc

Use one of the returned values (for example, USA or Germany) as your targetCountry.

let cancelledJobThreshold        = 3;
let uniqueCancelledUserThreshold = 2;
let pendingPickupThreshold       = 3;
let abortedJobThreshold          = 2;
let lookback = 6h;
let targetCountry = "USA";   // change to your region (see the discover query above)
let printerHealth =
    UniversalPrintPrinterHealth_CL
    | extend PrinterState    = tostring(PrinterStatusDetails.PrinterState),
             IsAcceptingJobs = tobool(PrinterStatusDetails.IsAcceptingJobs),
             ErrorCount      = array_length(PrinterStatusDetails.PrinterStateReasonErrors),
             Country         = tostring(LocationInfo.CountryOrRegion),
             City            = tostring(LocationInfo.Locality)
    | extend IsBadHealth = (PrinterState == "stopped") or (IsAcceptingJobs == false) or (ErrorCount > 0)
    | summarize arg_max(TimeGenerated, PrinterState, IsBadHealth, Country, City) by PrinterId
    | extend LatestHealth = iff(IsBadHealth, "Bad", "Healthy")
    | project PrinterId, LatestHealth, LatestHealthState = PrinterState, Country, City;
UniversalPrintJob_CL
| where TimeGenerated >= ago(lookback)
| summarize arg_max(TimeGenerated, JobLifecycleEvent, JobState, CreatedBy,
                    PrinterId, PrinterName, PrinterShareName),
            ReadyForPrinterDateTime = max(ReadyForPrinterDateTime)
    by JobId
| extend Printer = coalesce(PrinterName, PrinterShareName, "(unassigned)")
| extend Outcome = case(
        JobState == "completed",                "Completed",
        JobState == "canceled",                 "Cancelled",
        JobState == "aborted",                  "Aborted",
        JobLifecycleEvent == "readyForPrinter", "WaitingForPickup",
                                                "Other")
| summarize
        WaitingForPickup     = countif(Outcome == "WaitingForPickup"),
        Completed            = countif(Outcome == "Completed"),
        Cancelled            = countif(Outcome == "Cancelled"),
        Aborted              = countif(Outcome == "Aborted"),
        UniqueUsers          = dcount(CreatedBy),
        UniqueCancelledUsers = dcountif(CreatedBy, Outcome == "Cancelled")
    by Printer, PrinterId
| join kind=leftouter printerHealth on PrinterId
| extend Country = coalesce(Country, "(no location)")
| extend NeedsAttention =
        Cancelled            >= cancelledJobThreshold
        or UniqueCancelledUsers >= uniqueCancelledUserThreshold
        or WaitingForPickup  >= pendingPickupThreshold
        or Aborted           >= abortedJobThreshold
| extend AttentionReason = iff(
    NeedsAttention,
    strcat_array(array_concat(
        iff(Cancelled >= cancelledJobThreshold,
            pack_array(strcat(Cancelled, " cancelled jobs")), dynamic([])),
        iff(UniqueCancelledUsers >= uniqueCancelledUserThreshold,
            pack_array(strcat(UniqueCancelledUsers, " unique users with cancelled jobs")), dynamic([])),
        iff(WaitingForPickup >= pendingPickupThreshold,
            pack_array(strcat(WaitingForPickup, " jobs waiting for pickup")), dynamic([])),
        iff(Aborted >= abortedJobThreshold,
            pack_array(strcat(Aborted, " aborted jobs")), dynamic([]))
    ), " | "),
    "Healthy")
| where NeedsAttention and Country =~ targetCountry
| project Printer, PrinterId, Country, City, AttentionReason,
          WaitingForPickup, Completed, Cancelled, Aborted,
          UniqueCancelledUsers, UniqueUsers,
          LatestHealth = coalesce(LatestHealth, "(no health data)"), LatestHealthState

Query 4 — Completed volume by printer

Summarizes completed jobs by printer (for capacity and placement decisions) and measures three values:

  • Jobs — the number of completed print jobs.
  • TotalPages — total pages printed, calculated as PageCount × Copies so multi-copy jobs are counted correctly.
  • ColorPages — the subset of TotalPages printed in color (Details.ColorMode == "color"). Color is a per-job setting, so a color job's pages all count as color.

Note

Details configuration fields such as PageCount, Copies, and ColorMode are populated when a job completes, which is why Query 4 and Query 5 filter to JobState == "completed". To count physical sheets instead of document pages (duplex and pages-per-sheet applied), substitute Details.MediaSheetCount for PageCount × Copies.

let lookback = 30d;
UniversalPrintJob_CL
| where TimeGenerated >= ago(lookback)
// Collapse all lifecycle-event rows to the job's final snapshot.
| summarize arg_max(TimeGenerated, JobState, PrinterId, PrinterName, PrinterShareName, Details) by JobId
| where JobState == "completed"
| extend Printer  = coalesce(PrinterName, PrinterShareName, "(unassigned)"),
         Pages    = toint(Details.PageCount),
         Copies   = toint(Details.Copies),
         IsColor  = tostring(Details.ColorMode) == "color"
| extend TotalPages = Pages * iff(Copies > 0, Copies, 1)
| summarize
        Jobs       = count(),
        TotalPages = sum(TotalPages),
        ColorPages = sumif(TotalPages, IsColor)
    by Printer, PrinterId
| sort by TotalPages desc

Query 5 — Completed volume by user

The same measures grouped by the submitting user (CreatedBy) — useful for chargeback and quota conversations.

let lookback = 30d;
UniversalPrintJob_CL
| where TimeGenerated >= ago(lookback)
| summarize arg_max(TimeGenerated, JobState, CreatedBy, Details) by JobId
| where JobState == "completed"
| extend User    = coalesce(CreatedBy, "(unknown)"),
         Pages   = toint(Details.PageCount),
         Copies  = toint(Details.Copies),
         IsColor = tostring(Details.ColorMode) == "color"
| extend TotalPages = Pages * iff(Copies > 0, Copies, 1)
| summarize
        Jobs       = count(),
        TotalPages = sum(TotalPages),
        ColorPages = sumif(TotalPages, IsColor)
    by User
| sort by TotalPages desc

Use the queries

After a query returns the data you want, you can surface it in several ways. All of them work with any of the queries above. For an overview of the full set of Azure Monitor visualization options, see Analyze and visualize monitoring data.

Build a dashboard

Pin a query to an Azure dashboard so the view is always one click away — for example, Query 1 for the fleet attention view or Query 4 for print volume. For a screenshot-by-screenshot walkthrough — saving a query, the Pin to dashboard button, and customizing the tile — see Create and share dashboards of Log Analytics data; for creating and opening dashboards (with screenshots of the portal Dashboard menu), see Create a dashboard in the Azure portal.

  1. Run the query in the workspace Logs blade.
  2. Select Pin to > Azure dashboard. This button usually appears above the results, but depending on your Azure portal layout and window width it can be tucked under or next to the Save button on the query toolbar. The Log Analytics dashboard tutorial shows the button location in a screenshot.
  3. Choose an existing dashboard or create a new one, then select Pin.
  4. Open the dashboard and resize the tile. Select the tile's Edit (pencil) to adjust the time range or switch between Results (table) and Chart views.

When the tile loads, you'll know it worked by the result: Query 1 renders as a table with one row per printer (printers needing attention sorted to the top), and Query 4 renders as a bar chart of completed pages per printer. That's what a healthy, working dashboard tile looks like — the dashboard tutorial shows screenshots of finished sample tiles for reference.

To find a pinned tile later: select the menu icon in the top-left corner of the Azure portal, select Dashboard, then use the dropdown at the top of the page to pick your dashboard (private dashboards appear under My dashboards). The Azure portal dashboards article shows this navigation in a screenshot.

For a richer, interactive experience, use an Azure Monitor workbook instead. To learn more, see Azure Monitor workbooks overview and Create or edit an Azure Workbook.

  1. In the workspace, select Workbooks > New.
  2. Select Add > Add query, paste a query, and select Run query.
  3. Use Visualization to switch between a grid, bar chart (for example, Cancelled by Printer, or ColorPages by Printer), or tiles.
  4. Optionally add a parameter for the time range so viewers can change the window without editing KQL.
  5. Select Done editing, then Save.

Create an alert

Query 3 is built for alerting: it returns only flagged printers in the region you specify, so the rule can fire whenever the result has rows.

Create the alert rule in the portal

For full details on log search alert rules, see Create a log search alert rule.

  1. In the Azure portal, go to Monitor > Alerts > Create > Alert rule.
  2. Scope: select the Log Analytics workspace that holds your Universal Print tables.
  3. Condition: choose Custom log search and paste Query 3 (set targetCountry first). Configure the measurement:
    • Measure: Table rows
    • Aggregation type: Count
    • Operator: Greater than
    • Threshold value: 0
  4. Set the evaluation behavior. Because the query already windows with ago(6h), set Aggregation granularity to 6 hours and Frequency of evaluation to how often you want it to run (for example, 1 hour).
  5. Actions: attach or create an action group to deliver the notification (email, SMS, Teams, or webhook). See Action groups.
  6. Details: name the rule (for example, Universal Print printers needing attention), set a severity, and select Create.

Note

Because the query already filters to flagged printers, the alert should fire whenever the result has any rows. If you prefer the alert rule to own the time range instead of the query, remove the ago(lookback) filters and set the period in the rule.

Create the alert rule with the Azure CLI

Save Query 3 to a file named attention-alert.kql, then run:

# Action group that sends email
az monitor action-group create \
  --name "ag-printer-attention" \
  --resource-group "<resource-group-name>" \
  --short-name "PrtAttn" \
  --action email admin "<your-email>"

ag=$(az monitor action-group show -g "<resource-group-name>" -n "ag-printer-attention" --query id -o tsv)

# Scheduled-query alert: fire when Query 3 returns any rows
az monitor scheduled-query create \
  --name "UP-printers-need-attention" \
  --resource-group "<resource-group-name>" \
  --scopes "<workspace-resource-id>" \
  --condition "count 'rows' > 0" \
  --condition-query rows="@attention-alert.kql" \
  --evaluation-frequency 1h \
  --window-size 6h \
  --action-groups "$ag" \
  --severity 2 \
  --description "Fires when a Universal Print printer needs attention in the target region."

Replace <resource-group-name>, <your-email>, and <workspace-resource-id> (the full resource ID of your Log Analytics workspace) with your own values.

Export to Excel

To analyze the data in a spreadsheet, export any query's results directly from the Logs blade.

  1. Run the query in the workspace Logs blade.
  2. On the toolbar, select Export > Export to CSV (choose all columns or displayed columns).
  3. Open the downloaded .csv file in Excel. To keep it refreshable, in Excel use Data > Get Data > From Text/CSV, or From Web to point at the Log Analytics API.

Export to Power BI

For a refreshable report or shared dashboard, send a query to Power BI. The Export menu offers two paths:

  • Export to Power BI (M query) — downloads a .txt file containing the Power Query (M) for your query. In Power BI Desktop, select Get data > Blank query, open the Advanced Editor, and paste the M query. This keeps the report refreshable against your workspace.
  • Export to Power BI (new dataset) — creates a dataset in the Power BI service directly from your query, which you can then build reports and dashboards on.

For step-by-step guidance and the permissions required, see Log Analytics integration with Power BI.

Tip

Because Power BI refreshes against your workspace, your report stays current as new telemetry arrives. Build on Query 1 for a fleet-wide attention view (add slicers for Country, NeedsAttention, or Printer), or on Query 4 and Query 5 for volume and chargeback reporting.

Customize the queries

  • Tune sensitivity — raise or lower the four threshold let values. Smaller tenants typically need lower thresholds; high-volume tenants higher ones.

  • Change the window — edit lookback (for example, 1h, 24h, 7d). If you change it for the alert, keep the rule's Aggregation granularity in sync.

  • Alert on multiple regions — replace the final filter in Query 3 with:

    | where NeedsAttention and Country in~ ("United States", "Germany")
    
  • Filter or group by other location fieldsCountry is just one field on the printer's LocationInfo object (carried on UniversalPrintPrinterHealth_CL). You can surface and filter on any location field — Site, Building, Floor, Room, City, or Organization — to scope a dashboard or alert to a specific place. Extend the printerHealth block to project the fields you want:

    | extend Building     = tostring(LocationInfo.Building),
             Site         = tostring(LocationInfo.Site),
             Floor        = tostring(LocationInfo.FloorDesc),
             City         = tostring(LocationInfo.Locality),
             Organization = tostring(LocationInfo.Organization)
    

    Then filter (or group) on them instead of, or in addition to, Country. For example, to alert only on a specific building:

    | where NeedsAttention and Building =~ "Building 42"
    

    See the LocationInfo object for the full list of available fields. As with Country, location fields are only populated for printers that have reported health at least once and have that field configured.

  • Keep thresholds consistent — Query 1 and Query 3 use the same signal logic. If you change a threshold in one, change it in the other so your dashboard and alert agree.

Azure Monitor references

See also