Repository navigation
Architecture
How a run is put together, what it produces, and how it reaches the API.
managedreportsrunner.exe is a .NET 10 console application. There is no
service and no background loop — Windows Task Scheduler starts the process,
it collects, transmits and exits.
| Layer | Where | Responsibility |
|---|---|---|
| Entry point and CLI | src/Program.cs |
Argument parsing, configuration layering, dependency injection, command handlers |
| Configuration |
src/Configuration/, src/Shared/
|
Settings layering, trusted-file check, credential store, typed settings. src/Shared/ is compiled into the app as well |
| Collection orchestration | src/Services/ModularDataCollectionService.cs |
Runs modules, caches results, builds the unified payload |
| osquery |
src/Services/OsQueryService.cs, ModularOsQueryService.cs
|
Loads module query packs, shells out to osqueryi, parses JSON |
| Module processors | src/Services/Modules/ |
One class per module; turns raw results into typed models |
| Models | src/Models/Modules/ |
The payload shape |
| Transport | src/Services/ApiService.cs |
Gzip, identity headers, idempotency, retries |
| Support | src/Services/ |
WMI helper, PowerShell runner, bounded child processes, EDID reader, hardware serial, management log survey, usage service, logging |
A second project, usagetracker/, builds usagetracker.exe. A third,
src/App/, builds the Managed Reports Runner App, the WinUI 3 front end
that ships in every package for editing settings, starting a run and reading
logs. It is not part of the collection path and is not in the solution file.
Every PowerShell, osquery and command-line child the runner starts goes
through BoundedProcess, which reads both pipes from the start and kills the
whole process tree when a time limit passes (two minutes unless the caller sets
its own), so one stalled child cannot hold a run open past the scheduled
task's 30-minute limit.
- Parse
-vshorthand into--verbose=N, and attach to the parent console if any verbose flag is present. - Move any readable credential into the protected store, then build configuration from the command-line overrides, the registry keys, the environment and, when trusted, the YAML file. See Configuration.
- Configure Serilog and the DI container.
- Dispatch the command. For
runwith no module filter:-
IDataCollectionService.CollectAndSendDataAsyncchecks the recordedLastRunTime. If the last run was insideMaxDataAgeMinutesand--forcewas not given, the run stops here. -
ModularOsQueryServicereadsenabled-modules.jsonand each namedmodules\<id>.json, merging their queries into one pack. If a module file is missing fromProgramDatait is restored from the copy inProgram Files. -
osqueryi.exe --jsonis invoked with the combined pack. - Each module processor receives the raw result rows plus the device id, and enriches them from WMI, the registry, PowerShell and event logs.
- Each module's typed output is written to
cache\<timestamp>\<module>.json. - The results are merged into one
UnifiedDevicePayloadand written tocache\<timestamp>\event.json. - The payload is POSTed to
/api/v1/events. - On success,
LastRunTimeis updated in the registry.
-
--hello takes the shortest path: one osquery system_info query for the
serial, UUID and computer name, and a POST of the envelope alone. Nothing is
cached.
--run-module and --run-modules take a shorter path: only the named modules
are processed, but they still produce one event.json and one HTTP request for
the whole run. That is deliberate: a request per module would send a five-module sweep as
five requests with five identical metadata envelopes.
event.json holds the payload that is POSTed, indented for reading. The exact
request body of each attempt, before compression, is written to
logs\<timestamp>\unified_payload_<serial>_attempt<n>.json. Property names
are camelCase.
{
"metadata": {
"deviceId": "11111111-2222-4333-8444-555555555555",
"serialNumber": "SERIAL123",
"collectedAt": "2026-03-04T09:14:22Z",
"clientVersion": "2026.03.04.0912",
"platform": "Windows",
"collectionType": "Full",
"enabledModules": ["inventory", "system", "hardware"],
"additional": {}
},
"events": [
{
"moduleId": "system",
"eventType": "info",
"message": "System module collected successfully",
"timestamp": "2026-03-04T09:14:22Z",
"details": {}
}
],
"inventory": {},
"system": {},
"hardware": {},
"management": {},
"installs": {},
"security": {},
"identity": {},
"network": {},
"peripherals": {},
"applications": {}
}In the request body, modules that did not run are omitted rather than sent
empty. Every module
object carries four fields from the common base: moduleId, moduleVersion,
collectedAt and deviceId.
Each entry in events carries the moduleId it came from. That is what lets
the API replace one run's events for a module with the next run's, so an error
that a later run cleared does not stay on the dashboard.
moduleVersion is not a hand-maintained number. build.ps1 generates
ModuleVersions.g.cs at build time by asking git for the commit date of that
module's processor, models and query pack, formatted yyyy.MM.dd.HHmm. A
module's version therefore changes exactly when its code changes.
The serial number is the routing key, and the agent refuses to invent one.
HardwareSerial takes the first usable value from, in order: osquery
system_info.hardware_serial, osquery chassis_info.serial when the results
include it, WMI Win32_BIOS.SerialNumber, then WMI
Win32_SystemEnclosure.SerialNumber. The WMI step means a device still
reports when osquery is missing or returns nothing. Placeholders are rejected:
0, System Serial Number, To be filled by O.E.M., Default string,
None, Not Specified, and anything starting 00000000. If no usable serial
is found, collection throws rather than falling back to the hostname. The
inventory module also strips a few default Windows hostname prefixes, such as
WIN- and DESKTOP-, when firmware puts one in front of the serial.
The deviceId is a hardware UUID, resolved in order: osquery
system_info.uuid (rejecting the all-zero GUID), then
HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid, then a SHA-256 of the CPU
id joined with the baseboard serial.
POST /api/v1/events, with:
| Header | Value |
|---|---|
Content-Type |
application/json |
Content-Encoding |
gzip, when compression is on |
User-Agent |
ReportMate/<version> |
X-API-Key |
ApiKey, if configured |
X-Client-Passphrase |
Passphrase, if configured |
X-Device-Serial |
The device serial |
X-Device-Uuid |
The hardware UUID |
X-Device-Name |
The device name, when known |
X-Platform |
Windows |
X-Client-Version |
Agent version |
Idempotency-Key |
A UUIDv5 of the collection |
The identity headers are mirrored deliberately: a body the server cannot parse still arrives with enough context to attribute the failure to a device.
The idempotency key is a UUIDv5 in the RFC 4122 URL namespace over the name
reportmate:{serial}:{module}:{collectedAt}, with collectedAt formatted
yyyy-MM-ddTHH:mm:ssZ and {module} set to unified for the unified payload.
This is the same construction the macOS
client uses. It is identical across the retries of one collection and different
across collections, so a retry after a timeout the server actually processed
cannot double-count usage rows.
Retries are governed by MaxRetryAttempts (default 3). Two response codes get
special handling:
- 400 — treated as "this server cannot read a compressed body". The client drops to uncompressed for the rest of the run and retries. This is what lets the client roll out ahead of the API rather than behind it.
- 404 — treated as "the endpoint is not deployed yet". The run is reported as success and the payload stays cached.
Other endpoints the client uses: GET /api/v1/health for the connectivity
check performed before a transmit, and POST /api/v1/device plus
GET /api/v1/device/{deviceId} on the older device-registration path.
Every collection writes a complete event.json before any HTTP request is
made, so a failed transmission never loses the data. managedreportsrunner.exe transmit finds the newest timestamped cache directory, deserializes its
event.json, tests /api/v1/health, and re-POSTs the payload whole. Because
the idempotency key is derived from the payload's own collectedAt, a replay
of an already-accepted payload is deduplicated server side.
There is no internal timer. Four scheduled tasks run as SYSTEM, plus one
per-user task for the usage tracker. Their definitions come from
module-schedules.json; see Installation for the table.
MIT licensed. reportmate.app