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
ApplicationNamemust match the registration. YourSystemNamemust 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.
SetStatusSuccessAsyncrecords a run, not a success. It reports "no error", which is not the same thing:LastSuccessis never stamped by this library, so a system reporting through it shows a blank "last success" on the Dashboard for ever. Judge health byLastRunand the absence of a recentLastErroruntil 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"withvalue: 91merges;$"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
Monitor.Clientreferenced, project onnet10.0- A Serilog sink configured
ApplicationName,SystemNameandUrlin configuration; the key in user-secrets or the environmentMonitorClient.Instanceassigned once at startup, withResilientClientKeyApiConnectionEnsureSystemExistsAsync()called at startup- A status reported on start, on stop, and at least once per
HeartBeatSeconds SetStatusErrorAsyncon the failure pathsNotifyAsyncfor events a person should act on — varying detail invalue, so repeats collapse- The returned
boolchecked somewhere, at least at startup - 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
- Microsoft.Extensions.Http.Polly (>= 10.0.10)
- Serilog (>= 4.4.0)