Monitor.Client 2.2.0.1

Monitor.Client

The library an application references to report its own status and notifications to an Lds.Monitor Dashboard, where they appear on the wall screen and in the notification lists.

Verified against version 2.2.0, 2026-09-02. Monitor.ClientDemo in the same repository exercises everything below and is the executable version of this page.

Reading this from another repository? The package unpacks this file to %UserProfile%\.nuget\packages\monitor.client\<version>\readme.md, so it is on disk next to the assembly you are compiling against. If your project has a CLAUDE.md/AGENTS.md, point it there — that is the fastest way to make sure the next change to your integration is made against the current contract.


1. The model: applications, systems, statuses, notifications

Everything you report is addressed by two names you choose:

  • ApplicationName — your application. It must match the name your client key was registered under.
  • SystemName — a part of it that has a life of its own: a nightly job, a queue consumer, an import. One application usually reports several.

The pair identifies a system, and a system has exactly two kinds of traffic:

What it is Lifetime
Status The system's current state — one record per pair, overwritten on every report Now
Notification A single event worth someone's attention Until a human marks it handled

Report a status on a schedule (it answers "is this thing alive, and what is it doing"). Report a notification when something happened that a person should see. Do not use notifications as a log.

Casing does not create a second system: the pair is stored lower-cased, so Payroll/Nightly and payroll/nightly are the same system. The names keep the casing you reported them with, for display.

2. Install

dotnet add package Monitor.Client

Requires net10.0. It brings Microsoft.Extensions.Http.Polly and Serilog with it.

3. Get a client key

An administrator issues one in the Dashboard's management area (/admin/clients), registered for your ApplicationName.

  • The key is shown once, at issue, and stored hashed. A lost key is reissued, never recovered.
  • A key is {keyId}_{secret} in full. Half of one authenticates as nothing.
  • Your ApplicationName must match the registration. Your SystemName must be one the registration permits — a registration listing no system names permits any, which is the usual setup for an application that adds systems over time.
  • Rotation without downtime: a second key is issued and works alongside the first; deploy it, then the first is revoked. There is no forced expiry.
  • Treat it like a password. It belongs in user-secrets, the environment, or a secret store — never in a file you commit.

You do not need to know anything about tenants. The key belongs to one, and the Dashboard stamps everything you report with it; there is no tenant argument or header for you to set.

4. Set it up, once, at startup

using Monitor.Client;
using Serilog;

// Required, not optional - see section 8. Without a sink, a client that has stopped
// reporting is indistinguishable from one that is fine.
Log.Logger = new LoggerConfiguration().WriteTo.Console().CreateLogger();

MonitorClient.Instance = new MonitorClientConnection(
  new ResilientClientKeyApiConnection(monitorUrl, clientKey),
  applicationName: "Payroll",
  systemName: "Nightly") {
    DefaultCategory = "Batch",         // optional, used when a call names none
    DefaultProcessName = "PayrollJob"  // optional, same
  };

monitorUrl is the root of the Dashboard (https://monitor.example.com/), not a page within it. A missing scheme and a missing trailing slash are both corrected for you.

Construct this once and keep it for the life of the process. Each MonitorClientConnection creates its HttpClients up front; building one per call leaks sockets. MonitorClient.Instance is a static, and every member is safe to call concurrently.

The values belong in configuration, layered the way any secret is:

// appsettings.json - committed
{ "Monitor": { "Url": "https://monitor.example.com/", "ApplicationName": "Payroll", "SystemName": "Nightly" } }
dotnet user-secrets set "Monitor:ClientKey" "<key>"    # or an environment variable in production

Opting out

Not every deployment of your application has a Monitor to report to:

MonitorClient.Instance = NoMonitor.Client;   // every call is a no-op returning false

MonitorClient.IgnoreMissingConfiguration = true is the weaker version: calls made before Instance is assigned return false instead of throwing. It does not cover EnsureSystemExistsAsync, which always requires a real connection and throws NullReferenceException without one.

5. Reporting a status

await MonitorClient.SetStatusSuccessAsync(CurrentStatus.Running, "Processed 412 records");
await MonitorClient.SetStatusErrorAsync(CurrentStatus.Stopped, "Could not reach the bank file share");

CurrentStatus is Stopped (0), Batch (1 — running as a batch job), Running (2 — running as a service) or Disabled (9).

Each report overwrites CurrentStatus and LastMessage, and stamps LastRun. An error report also stamps LastError.

SetStatusSuccessAsync records a run, not a success. It reports "no error", which is not the same thing: LastSuccess is never stamped by this library, so a system reporting through it shows a blank "last success" on the Dashboard for ever. Judge health by LastRun and the absence of a recent LastError until that is fixed.

A system that reports a status before anybody created it is created for you — the Dashboard registers it on first report. EnsureSystemExistsAsync() does the same without reporting a state, which is worth calling at startup so a system that has not run yet is already visible:

await MonitorClient.EnsureSystemExistsAsync();

The shape that fits a hosted service

public class NightlyJob(ILogger<NightlyJob> logger) : BackgroundService {
  protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
    await MonitorClient.EnsureSystemExistsAsync();
    await MonitorClient.SetStatusSuccessAsync(CurrentStatus.Running, "Started");

    try {
      while (!stoppingToken.IsCancellationRequested) {
        var processed = await RunOnceAsync(stoppingToken);
        await MonitorClient.SetStatusSuccessAsync(CurrentStatus.Running, $"Processed {processed}");
        await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
      }
    } catch (Exception x) when (x is not OperationCanceledException) {
      await MonitorClient.SetStatusErrorAsync(CurrentStatus.Stopped, x.Message);
      throw;
    } finally {
      await MonitorClient.SetStatusSuccessAsync(CurrentStatus.Stopped, "Shutting down");
    }
  }
}

Report a status at least as often as the system's configured HeartBeatSeconds, where an administrator has set one — that is what the Dashboard compares against to decide the system has gone quiet.

6. Reporting a notification

await MonitorClient.NotifyAsync("Bank file was rejected");
await MonitorClient.NotifyAsync("Queue depth exceeded", value: depth, priority: 1, category: "Capacity");
Argument Meaning
notificationMessage Required. What happened, in a form a person reads on a wall screen
value Optional, appended to the message. Any object; its ToString() is used
priority 1 is the highest, 4 the lowest. Default 3. Anything below 1 is floored to 1
category Optional grouping. Falls back to DefaultCategory, then to the system's configured setup
processName Optional. Falls back to DefaultProcessName

A repeat is not a second notification. The Dashboard compares application, system, process, message and category; a match against an unhandled notification increments its SeenCount and updates LastSeen instead of inserting. A loop reporting the same failure every minute therefore produces one line with a count, not a wall of them. Two consequences worth designing for:

  • Put the varying part in value, not in the message, when you want repeats to collapse. "Queue depth exceeded" with value: 91 merges; $"Queue depth {depth} exceeded" does not.
  • A repeat can raise the priority, never lower it. Reporting the same thing at priority 1 after a 3 promotes the existing notification; reporting it at 4 afterwards leaves it at 1.

Once a person marks it handled, the next report starts a fresh notification.

An administrator can configure defaults per system — category, priority, how long a handled notification stays visible — in the Dashboard. Those are resolved server-side, and apply only to the arguments you did not name.

Marking your own notifications handled

An application that fixes its own problem can close the notification it raised:

await MonitorClient.Instance.HandleNotificationAsync(id, "Retried successfully", "PayrollJob");
await MonitorClient.Instance.HandleNotificationBySystemAsync(
  "Cleared at startup", "PayrollJob", systemName: "Nightly", category: "Capacity");

The first returns the id it handled, or 0. The second returns how many it handled. Note that a client key may only use HandleNotificationBySystemAsync for system names its registration lists explicitly — that request carries no application name to check against, so a permit-any registration is not enough here.

7. What not to use

Don't Why
ResilientApiConnection / BasicApiConnection They send no credential. Every call is refused with 401 by any Dashboard that requires client keys — which is all of them since 2026-08-20
ApiCurrentInfoRepository, ApiMachineInfoRepository, ApiResourceInfoRepository, ApiTaskInfoRepository Public surface left behind by an earlier design; every method throws NotImplementedException. Reporting machine telemetry is the Monitor Agent's job, not yours
WatcherBase / IWatcher Described elsewhere as an extensibility point, but CheckResult and CheckAsync are internal, so it cannot be derived from outside Monitor.Shared. Poll on your own timer and call NotifyAsync/SetStatusAsync instead
ISystemStatusRepository / INotificationRepository directly Their read and administration methods are for the Dashboard, and several throw NotSupportedException here. IClientConnection is the supported surface, and the only one the compatibility guarantee covers

8. Failure, and how to see it

Every method catches, logs and returns false (or 0). That is deliberate — a monitoring call must never take down the application it monitors — but it means a wrong key looks exactly like an unreachable server unless something is receiving Log.Error. Configure a Serilog sink before you deploy a key, not after.

if (!await MonitorClient.SetStatusSuccessAsync(CurrentStatus.Running, message))
  logger.LogWarning("Monitor did not accept the status; the reason is in the Serilog error above");

What the answers mean:

Symptom Cause
401 in the log No valid key was presented: missing, mistyped, half a key, or revoked
403 The key is valid, but its registration does not cover this application or this system name
404 The URL is not the root of a Dashboard
false with a TaskCanceledException The Dashboard is unreachable. Already retried — see below
false with an ArgumentException An empty ApplicationName, SystemName or message; validated before the call is made

ResilientClientKeyApiConnection retries 5xx and 408 three times with exponential backoff, and deliberately not 401 or 403 — a rejected key should fail at once and visibly.

Once a call reaches the Dashboard it is durable: statuses and notifications land in a queue that survives the store being down and flushes when it comes back. What is not covered is the Dashboard being unreachable from your process — that report is lost once the retries are spent, so do not use notifications where you need an audit trail.

To find out what the Dashboard actually answered, which the bool cannot tell you, run Monitor.ClientDemo with your URL and key: its option 4 sends the same request and prints the status code.

9. Checklist

  1. Monitor.Client referenced, project on net10.0
  2. A Serilog sink configured
  3. ApplicationName, SystemName and Url in configuration; the key in user-secrets or the environment
  4. MonitorClient.Instance assigned once at startup, with ResilientClientKeyApiConnection
  5. EnsureSystemExistsAsync() called at startup
  6. A status reported on start, on stop, and at least once per HeartBeatSeconds
  7. SetStatusErrorAsync on the failure paths
  8. NotifyAsync for events a person should act on — varying detail in value, so repeats collapse
  9. The returned bool checked somewhere, at least at startup
  10. Verified against a real Dashboard: the system appears, its status changes, a notification arrives

Version history

See CHANGELOG.md, which ships in the package beside this file — including what changed in 2.0, and what "a client key is now required" means for an application still on an older connection.

No packages depend on Monitor.Client.

2.2.0 - no code change. The readme is now a full integration guide, and version history moved to CHANGELOG.md. The Dashboard requires a client key: an application still using ResilientApiConnection or BasicApiConnection is refused with 401, and keyless watch reports stop with it.

.NET 10.0

Version Downloads Last updated
3.2.0 5 9/13/2026
3.1.0 8 9/10/2026
3.0.3 3 9/10/2026
3.0.1 2 9/8/2026
2.2.0.1 2 9/3/2026
1.1.2 2 8/30/2026
1.0.1 124 11/11/2025
1.0.0 40 11/11/2025
1.0.0-CI-20251111-152312 42 11/11/2025