Monitor.Client 3.2.0

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 the unreleased build of 2026-09-06 (3.0.0). Monitor.WorkerDemo in the same repository is the executable version of this page - every sample here comes from it, and it has been run against a real Dashboard. Monitor.ClientDemo beside it is the interactive tool for answering "is this key right".

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, from one connection — see section 5.

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, Microsoft.Extensions.Configuration.Abstractions and Serilog with it.

3. Get a client key

An administrator issues one in the Dashboard's management area (Applications → Reporting applications, /applications/clients), registered for your ApplicationName. There is no way to report without one - the Dashboard accepted unauthenticated reports during a migration years-old by now, and that door was closed permanently on 2026-09-06. This library refuses to start without a key rather than let you find out from a wall screen that never showed your application.

  • 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

In a host

using Monitor.Client;

builder.Services.AddMonitorClient(builder.Configuration);

That reads the Monitor section, registers an IClientConnection, and refuses to start if the section cannot work — naming the key that is wrong. An application that is meant to run without a Dashboard says so with Enabled rather than by leaving the settings blank.

// appsettings.json - committed
{
  "Monitor": {
    "Url": "https://monitor.example.com/",   // the root of the Dashboard, not a page within it
    "ApplicationName": "Payroll",            // must match what the client key was registered for
    "SystemName": "Nightly",
    "Enabled": true,                         // false registers a no-op; nothing else is then required
    "TimeoutSeconds": 30,                    // total budget for one report, retries included
    "DefaultCategory": "Batch",              // optional, used when a call names none
    "DefaultProcessName": "PayrollJob"       // optional, same
  }
}
dotnet user-secrets set "Monitor:ClientKey" "<key>"    # or an environment variable in production

Then inject it wherever you report:

public class NightlyJob(IClientConnection monitor, ILogger<NightlyJob> logger) : BackgroundService {
  protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
    await monitor.EnsureSystemExistsAsync(stoppingToken);
    ...
  }
}

Failures are reported through your own logger. The library takes the host's ILogger, so whatever your application logs to is where you will see why a report did not arrive. It falls back to Serilog's static Log only when nothing was supplied — which is what code not using the container gets, and why the next section still configures one.

Before the host is built

AddMonitorClient gives you an IClientConnection from the container, which means you cannot report anything that happens before builder.Build() — a configuration section that will not bind, a database that will not open, a dependency that will not resolve. That is a startup failure nobody sees, and it is exactly the kind worth putting on a wall screen. Build one directly from the configuration instead:

var monitor = builder.Configuration.CreateMonitorClient();          // no container needed
await monitor.SetStatusAsync(CurrentStatus.Stopped, "Configuration is invalid", error: true);

Same section, same rules, same refusals as AddMonitorClient — including Monitor:Enabled: false, which answers the no-op here too. There is also an overload taking a MonitorClientOptions you assembled in code, and both take an optional ILogger; with none, failures go to Serilog's static Log, which before the host exists is usually the only logger there is.

The reporter it returns owns its connection — the socket pool every report goes through — so build one and keep it, and dispose it if you have somewhere to. Calling this and AddMonitorClient gives you two of them, which is two pools; if the same reporter should serve startup and the rest of the run, register the one you already have:

var monitor = builder.Configuration.CreateMonitorClient();
builder.Services.AddSingleton(monitor);      // instead of AddMonitorClient(builder.Configuration)

Without a host

With no configuration system at all, assign the static once at startup and configure a Serilog sink — with no ILogger to hand, that is the only place a failure is ever explained:

using Monitor.Client;
using Serilog;

Log.Logger = new LoggerConfiguration().WriteTo.Console().CreateLogger();

MonitorClient.Instance = new MonitorClientConnection(
  new ResilientClientKeyApiConnection(monitorUrl, clientKey) { Timeout = TimeSpan.FromSeconds(30) },
  applicationName: "Payroll",
  systemName: "Nightly") {
    DefaultCategory = "Batch",
    DefaultProcessName = "PayrollJob"
  };

monitorUrl is the root of the Dashboard; a missing scheme and a missing trailing slash are both corrected for you.

Build this once and keep it for the life of the process: the connection owns the socket pool every report goes through, so building one per call is a leak. Both it and the reporter are IDisposable if you have somewhere to dispose them; a process-lifetime static needs nothing, and every member is safe to call concurrently.

If you use the container and have code that cannot take a dependency, bridge the two once:

var host = builder.Build();
host.Services.UseMonitorClient();     // fills in MonitorClient.Instance from the container

A report cannot hold up your application

Every call has a total budget — 30 seconds by default, retries and backoff included — set with Monitor:TimeoutSeconds or Timeout on the connection. Every reporting method also takes a CancellationToken; pass the host's, and a shutdown stops waiting for a Dashboard that is not answering:

await monitor.SetStatusAsync(CurrentStatus.Stopped, "Shutting down", false, stoppingToken);

Opting out

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

{ "Monitor": { "Enabled": false } }

or in code, MonitorClient.Instance = NoMonitor.Client;. Either way every call succeeds and does nothing — true, because reporting nowhere on purpose is not a failed report, so the checks in your own code stay quiet. (It answered false until 3.0, which meant a deployment that had opted out logged a warning on every single call.)

MonitorClient.IgnoreMissingConfiguration = true is the weaker version: calls made before Instance is assigned return false instead of throwing, and it covers every member including EnsureSystemExistsAsync (which ignored it until 3.0). Prefer one of the two above — they say the same thing deliberately, where the flag also hides a startup step somebody simply forgot.

5. Reporting a status

await monitor.SetStatusAsync(CurrentStatus.Running, "Processed 412 records");
await monitor.SetStatusAsync(CurrentStatus.Stopped, "Could not reach the bank file share", error: true);

monitor here is the injected IClientConnection from section 4; through the static facade the same calls read MonitorClient.SetStatusAsync(...).

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. A report that is not an error also stamps LastSuccess; one that is stamps LastError instead. "Not an error" is what counts as a success — a status report that only proves the process is alive still moves LastSuccess, so use it to answer "has this run recently", not "did the work succeed". (Before 2.3 LastSuccess was never stamped at all, so a system reporting through this library showed it blank for ever.)

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 monitor.EnsureSystemExistsAsync();

The shape that fits a hosted service

Lifted from Monitor.WorkerDemo, which runs this against a real Dashboard:

public class NightlyJob(IClientConnection monitor, ILogger<NightlyJob> logger) : BackgroundService {
  protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
    // So the system is on the Dashboard from the moment this starts, not from its first report.
    await monitor.EnsureSystemExistsAsync(stoppingToken);
    await monitor.SetStatusAsync(CurrentStatus.Running, "Started", false, stoppingToken);

    try {
      while (!stoppingToken.IsCancellationRequested) {
        var processed = await RunOnceAsync(stoppingToken);
        await monitor.SetStatusAsync(
          CurrentStatus.Running, $"Processed {processed}", false, stoppingToken);
        await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
      }
    } catch (OperationCanceledException) {
      // The host is stopping. Not a fault, and not something to report as one.
    } catch (Exception x) {
      await monitor.SetStatusAsync(CurrentStatus.Stopped, x.Message, true, stoppingToken);
      throw;
    } finally {
      // Deliberately not the stopping token: this is the report that says the system went down, and it is
      // the one report worth a few seconds of a shutdown. A cancelled report is a report that did not
      // happen, so give it its own budget instead.
      using var goodbye = new CancellationTokenSource(TimeSpan.FromSeconds(5));
      await monitor.SetStatusAsync(CurrentStatus.Stopped, "Shutting down", false, goodbye.Token);
    }
  }
}

Through the static facade instead of an injected IClientConnection, every line is the same with MonitorClient. in front of it and the token as the last argument.

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.

Reporting for more than one system

Your registration covers the application and the system names it may report under, and several is the normal case. Ask the connection for each one — it shares the connection, the key and the sockets, so this is cheap enough to write where you use it:

var imports = monitor.ForSystem("Imports");
await imports.SetStatusAsync(CurrentStatus.Batch, "Ran in 41s");

Everything else behaves the same, and the defaults come with it. Disposing one of these does nothing on purpose — it borrows what it reports through; dispose the connection it came from. (Before 3.0 a second system meant a second connection and another socket pool.)

6. Reporting a notification

await monitor.NotifyAsync("Bank file was rejected");
await monitor.NotifyAsync("Order could not be shipped", value: orderNumber, priority: 1, category: "Fulfilment");
Argument Meaning
notificationMessage Required. What happened, in a form a person reads on a wall screen
value Optional. Appended to the message (in quotes, a quirk inherited from the stored procedure this replaced), so it is part of what makes a notification that notification — see below. 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. Naming none is a value: leave it out and the Dashboard applies the setup's default. Passing "" is not the same thing and was what the library itself did before 2.3, which is why configured default categories never appeared
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. Three consequences worth designing for:

  • Nothing that varies, in the message or in value. The value is appended to the message before the comparison is made, so "Queue depth exceeded" with value: 91 and then value: 92 are two notifications, exactly as $"Queue depth {depth} exceeded" would be. If you want repeats to fold, keep both constant and put the changing number in the status message instead — that is current state, and it is meant to be overwritten. (This page said the opposite until Monitor.WorkerDemo was run against a real Dashboard and the board showed one notification per attempt.)
  • value is for detail that identifies the event, not detail that changes with it — an order number, a file name, the thing you would want in the line on the wall screen.
  • 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 closes the notification it raised:

// By id, when you kept it.
await monitor.HandleNotificationAsync(id, "Retried successfully", "PayrollJob");

// By what you reported — the usual way, because an application rarely keeps the id.
await monitor.HandleOwnNotificationsAsync(
  "Bank file was rejected",              // the message, exactly as you reported it
  "Cleared: the bank file arrived",      // what to record about the handling
  "PayrollJob");                         // who handled it

That closes the notification a matching NotifyAsync would have folded into — same system, same process, same category, same message — and nothing else. Anything else the system has open, raised by something else about something that may still be wrong, stays open.

If you reported a value, pass the same value. It is appended into the stored message rather than kept beside it, so the message alone matches nothing:

await monitor.NotifyAsync("Queue depth exceeded", value: 91);
await monitor.HandleOwnNotificationsAsync("Queue depth exceeded", "Drained", "PayrollJob", value: 91);

category and processName are there for the same reason — pass what you reported, if it was not the connection's default. Matching is case-insensitive, because that is how the Dashboard decides whether two reports are the same notification.

There is no system-name argument: it closes notifications for the system this connection reports as. Use ForSystem("Imports") first to close one belonging to another of your systems.

Clearing everything a system has open

The wide one, and it says so:

await monitor.HandleAllNotificationsForSystemAsync("Cleared at startup", "PayrollJob");

A system is shared. This closes notifications something else raised, about things that may still be wrong, and a handled notification is one nobody looks at again. It is the right call for an application clearing whatever it left behind after a crash, and the wrong one for an application closing what it just raised.

Until 3.0 these were one method, and the difference between them was one optional argument left unset — which is not a difference anyone notices in review. There is now no argument you can forget that widens what gets closed.

Note that a client key may only handle notifications 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. Both calls are refused with 403 otherwise, and the reason is in your log.

7. What is not here

IClientConnection is the whole of it. As of 3.0 the package's public surface is that interface, the things needed to build one — MonitorClientSetup, MonitorClientFactory, MonitorClientOptions, MonitorClientConnection, ClientKeyApiConnection and its resilient twin, NoMonitor — plus MonitorApiException and the static MonitorClient. If IntelliSense does not offer it, it is not for you.

What used to be visible and is not any more:

Gone Why
ResilientApiConnection, BasicApiConnection's constructors They send no credential, and every Dashboard refuses that with 401. Their two remaining callers are inside the Monitor solution — an agent enrolling anonymously, and the Dashboard's own browser client, which authenticates by cookie — and neither is a reporting application
ApiCurrentInfoRepository, ApiMachineInfoRepository, ApiResourceInfoRepository, ApiTaskInfoRepository Deleted. Left behind by an earlier design, every method threw NotImplementedException, and nothing ever resolved them. Reporting machine telemetry is the Monitor Agent's job, not yours
ApiRequest, ApiNotificationRepository, ApiSystemStatusRepository, ApiServerRepository, ApiAgentRepository, ApiNotificationSetupRepository Internal. They are how the Dashboard's own client and the agent talk to the API — administration and telemetry, which a client key cannot do anyway

And one thing that is still visible but still not yours:

Don't Why
WatcherBase / IWatcher (in Monitor.Shared) 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

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 the log. Registered through AddMonitorClient, that is your host's own ILogger and there is nothing to set up. Constructed by hand, it is Serilog's static Log, and a sink has to exist before you deploy a key, not after.

if (!await monitor.SetStatusAsync(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 "did not finish within 30 seconds" The call spent its whole budget. Raise Timeout on the connection, or accept it
false logged at information, not error Your own token was cancelled. A shutdown mid-report is not a fault, and is not logged as one
false with a connection error 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 twice, one and three seconds apart, and deliberately not 401 or 403 — a rejected key should fail at once and visibly. The retries spend the same Timeout budget as the call itself, so the budget is the whole story.

One thing that is not a false: reporting before MonitorClient.Instance was assigned throws InvalidOperationException naming the setup step. That is a defect in your startup rather than a failed report, and it is worth finding on the first call — say so on purpose with NoMonitor.Client if reporting nowhere is what you meant.

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. To see a whole integration working instead, run Monitor.WorkerDemo - its Readme.md says what to watch for on the board.

9. Checklist

  1. Monitor.Client referenced, project on net10.0
  2. AddMonitorClient(builder.Configuration) at startup — or builder.Configuration.CreateMonitorClient() if something before builder.Build() has to report, or, with no host at all, the static assigned once and a Serilog sink configured
  3. ApplicationName, SystemName and Url in appsettings.json; the key in user-secrets or the environment
  4. IClientConnection injected where you report (or UseMonitorClient() if something needs the static), and ForSystem(...) where the application reports for more than one system
  5. EnsureSystemExistsAsync() called at startup
  6. A status reported on start, on stop, and at least once per HeartBeatSeconds
  7. The host's CancellationToken passed to every call, except the one that reports the shutdown
  8. SetStatusAsync(..., error: true) on the failure paths (the static MonitorClient also has a SetStatusErrorAsync shorthand; IClientConnection does not)
  9. NotifyAsync for events a person should act on — nothing that varies in the message or in value, so repeats fold into one line with a count instead of a wall of them (see section 6; the changing number belongs in the status message, which is meant to be overwritten)
  10. The returned bool checked somewhere, at least at startup
  11. 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.

3.0.0 - a report can no longer hold up the application making it: every call has a total timeout (30s by default, retries included) and takes a CancellationToken, and connections share one socket pool and are disposable. Registers with your container - AddMonitorClient(configuration) - and logs failures through the host's ILogger rather than only through the static Serilog. ForSystem(name) reports for several systems over one connection. A missing client key is refused at startup, not warned about. The published surface is now what a reporting application uses and nothing else. Full detail, including what changed in 2.3.0 before it, is in CHANGELOG.md beside the readme.

Version Downloads Last updated
3.2.0 6 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