Report each Windows event once, and persist the inventory already sent

Curated events were re-read from the newest 25 entries per channel on every
collect, and the server appended whatever arrived. One real event was
therefore stored again on every cadence tick for as long as it stayed in
that window, so event counts measured how long a box had been up rather
than what had happened to it.

The agent now keeps a per-channel watermark of the last Windows event
record id it reported, persisted under ProgramData so a service restart
does not replay history, and sends eventrecordid with each event. The
server dedupes on (assetid, eventchannel, eventrecordid) behind a unique
index, so a replay from an older agent cannot double-store either.

Six inventory keys the agent had been sending all along - bios, disks,
domain, memory, osinfo, processor - were parsed, logged as unpersisted,
and dropped. They now have columns and are written on check-in, along
with user context, pending reboot, and the cpupercent/memorypercent/
idleseconds fields that were declared in the contract but always null.

Session-scoped data cannot be read from the agent's LocalSystem session 0
at all: mapped drives and user-owned listeners do not exist there. A probe
runs in the interactive user's session and hands results back through a
spool file. It is not yet producing output on a live endpoint; the column
and panel are in place and read empty until that is fixed.

Also bounds the user-context script wait. It was WaitForSingleObject with
INFINITE, so a hung script pinned the command worker permanently.
This commit is contained in:
benjamin.baker
2026-09-04 19:09:12 -04:00
parent 3714b4bc99
commit 862b290545
18 changed files with 1633 additions and 39 deletions

View File

@@ -51,9 +51,44 @@ arrays. `adopt` is first-contact only.
}
],
"scheduledtasks": [ /* opaque array */ ],
"disks": [
{
"drive": "C:",
"label": "OS",
"filesystem": "NTFS",
"sizebytes": 511000000000,
"freebytes": 128000000000,
"usedbytes": 383000000000,
"freepercent": 25.0 // precomputed; a threshold is a direct compare
}
],
"bios": { /* opaque object: vendor, version, release date */ },
"memory": { /* opaque object: totalbytes, freebytes */ },
"processor": { /* opaque object: name, cores, logicalprocessors, maxclockmhz */ },
"osinfo": { /* opaque object: build, version, install date */ },
"domain": "shopfloor.example.com", // domain or workgroup membership
"pendingreboot": false, // omit when unknown; absent != false
"usercontext": { // omitted entirely when nobody is signed in
"netuse": [
{
"localname": "Z:",
"remotename": "\\\\fileserver\\programs",
"status": "OK",
"username": "SHOPFLOOR\\operator",
"persistent": true
}
],
"userlisteners": [
{ "localaddress": "0.0.0.0", "localport": 7878, "processid": 4512, "processname": "DncMain" }
],
"idleseconds": 45,
"username": "operator",
"collectedat": "2025-01-14T06:12:00Z"
},
"events": [
{
"eventid": 6008, // required per item; non-catalog IDs are dropped, not errored
"eventrecordid": 184412, // Windows record id; dedupe key, see notes
"eventchannel": "System",
"eventsource": "EventLog",
"eventtime": "2025-01-14T06:11:58Z",
@@ -81,6 +116,31 @@ Notes:
(`HKLM\SOFTWARE\[WOW6432Node\]GE Aircraft Engines\DNC\General:MachineNo`) or
the UDC `udc_settings_*.json` (`GeneralSettings.MachineNumber`), in that order
(DESIGN-DECISIONS-agent.md 9.4/9.5). `""` or `"9999"` means none.
- `eventrecordid` is the Windows event record id: monotonic and unique within a
channel on a machine. The agent uses it as a per-channel harvest watermark and
the server enforces a unique index on
`(assetid, eventchannel, eventrecordid)`, so one real event is stored exactly
once. It is nullable: an older agent omits it, and MySQL permits repeated
NULLs in a unique index, so those reports keep the previous append behavior
rather than failing ingest. Send it whenever the source record exposes one.
- `disks`/`bios`/`memory`/`processor`/`osinfo`/`usercontext` are stored verbatim
as JSON snapshots on the endpoint state, newest-wins. `domain` and
`pendingreboot` are scalar columns because the fleet view filters and alerts
on them.
- OMITTING a key is not the same as sending an empty value. Every one of these
fields is applied only when the key is PRESENT, so a report that omits it
preserves the last-known value. Send `null`/`[]` only when you mean "this is
genuinely empty now"; omit when you simply did not collect it this cycle.
- `pendingreboot` follows that rule strictly: omit it when the check failed. A
literal `false` asserts the endpoint is confirmed NOT holding a reboot.
- `usercontext` is gathered by a probe the agent runs inside the signed-in
user's session, because mapped drives, user-owned listeners and idle time do
not exist in the agent's own LocalSystem session 0. It is omitted when no one
is signed in, which is a normal unattended state, not a fault.
- `ports` carries LISTENING sockets only. The full connection table is available
on demand via the `network_connections` command instead, because established
connections churn constantly and would change every report for no diagnostic
gain.
## 3. Response
@@ -137,13 +197,23 @@ public sealed class EndpointReport
[JsonPropertyName("ports")] public object[]? Ports { get; set; }
[JsonPropertyName("interfaces")] public object[]? Interfaces { get; set; }
[JsonPropertyName("scheduledtasks")]public object[]? ScheduledTasks { get; set; }
[JsonPropertyName("disks")] public object[]? Disks { get; set; }
[JsonPropertyName("bios")] public object? Bios { get; set; }
[JsonPropertyName("memory")] public object? Memory { get; set; }
[JsonPropertyName("processor")] public object? Processor { get; set; }
[JsonPropertyName("osinfo")] public object? OsInfo { get; set; }
[JsonPropertyName("domain")] public string? Domain { get; set; }
[JsonPropertyName("pendingreboot")] public bool? PendingReboot { get; set; }
[JsonPropertyName("usercontext")] public object? UserContext { get; set; }
[JsonPropertyName("applications")] public object[]? Applications { get; set; }
[JsonPropertyName("events")] public EndpointEventRecord[]? Events { get; set; }
}
public sealed class EndpointEventRecord
{
[JsonPropertyName("eventid")] public int EventId { get; set; }
[JsonPropertyName("eventchannel")] public string? EventChannel { get; set; }
[JsonPropertyName("eventid")] public int EventId { get; set; }
[JsonPropertyName("eventrecordid")] public long? EventRecordId { get; set; }
[JsonPropertyName("eventchannel")] public string? EventChannel { get; set; }
[JsonPropertyName("eventsource")] public string? EventSource { get; set; }
[JsonPropertyName("eventtime")] public DateTimeOffset? EventTime { get; set; }
[JsonPropertyName("eventmessage")] public string? EventMessage { get; set; }
@@ -230,6 +300,11 @@ Allowlisted actions (server source of truth: `plugins/endpoints/commands.py`):
(`C:\ProgramData\UDC\MTConnect_UDC\Agent\run.bat`).
- `mtconnect_stop` - stop the running MTConnect UDC agent process.
- `mtconnect_status` - report whether the MTConnect UDC agent is running.
- `network_connections` - return the full TCP connection table with owning
process ids and names. Read-only diagnostic. The result is capped at 500
connections with a `truncated` flag in `detail`, because an oversized payload
rejected by the server returns nothing at all and a partial answer is more
useful than a failed one. Standard reports carry listening ports only.
### 7.1 Claim the next command (long-poll)