JSONL reports
ReportJson is an opt-in System.Text.Json serializer for ProcessKit's own report types —
Outcome, ProcessResult<string> / ProcessResult<byte[]>, ProcessGroupStats, RunProfile,
MemberInfo, and LimitEvidence — so a finished run, a group's resource snapshot, a member enumeration,
or a group's post-run resource-limit evidence can be logged as one self-describing JSON object per line
(JSONL), without hand-copying fields or hand-calling .ToString() on an enum. It ports the shape of
ProcessKit-rs's report-serde feature; see Coming from ProcessKit-rs for the wider
vocabulary map.
Nothing on ProcessResult/ProcessGroupStats/RunProfile/MemberInfo/LimitEvidence changed to add
this — it is a separate serializer you reach for explicitly, either through the ToReportJson() extension
methods or by passing one of ReportJson's JsonTypeInfo<'T> properties to JsonSerializer.Serialize
yourself.
- The schema
- Stable identifiers
- Secret hygiene
- AOT / trimming
- Versioning
- Writing a JSONL stream
- Reading a JSONL stream
The schema
Every line is one JSON object tagged with a stable "kind" identifier — never a raw union-case ordinal or
a .ToString() spelling — and every optional metric is present on every line, null when the
platform or the run could not report it. Time is always a number of fractional seconds
(duration_secs, total_cpu_time_secs, cpu_time_secs, elapsed_secs, …), never milliseconds.
kind | Source type | Fields |
|---|---|---|
exited / signalled / timed_out / unobserved | Outcome | code (int, exited only), signal_number (int, signalled only), reason (string, unobserved only) — the other two are null on every case that does not carry them |
process_result | ProcessResult<string> / ProcessResult<byte[]> | program, outcome, success, ok_codes (int array), duration_secs, truncated, total_lines, total_bytes |
process_group_stats | ProcessGroupStats | active_process_count, peak_process_count, total_cpu_time_secs, peak_memory_bytes, io_read_bytes, io_write_bytes, io_read_operations, io_write_operations |
run_profile | RunProfile | outcome, duration_secs, cpu_time_secs, peak_memory_bytes, io_read_bytes, io_write_bytes, io_read_operations, io_write_operations, samples, avg_cpu_cores |
member_info | MemberInfo | pid, ppid, exe_name, start_time (ISO-8601) |
limit_evidence | LimitEvidence | memory, processes, cpu — each one of "tripped" / "not_tripped" / "unknown" (LimitVerdict's stable identifiers) |
An embedded Outcome (inside process_result / run_profile) is the same tagged object as the top-level
one, under the outcome key. Each of a process_result line's total_lines and total_bytes fields is
independently null when that dimension was not counted (for example, raw pipeline captures count bytes
but not lines); a measured zero remains 0. success is the run's own Command.OkCodes verdict, so a
consumer never has to re-derive it from outcome/ok_codes itself.
Example: a run whose exit code 3 is in its own accepted-code set, as one line —
{"kind":"process_result","program":"tool","outcome":{"kind":"exited","code":3,"signal_number":null},"success":true,"ok_codes":[0,3],"duration_secs":1.5,"truncated":false,"total_lines":null,"total_bytes":null}
Stable identifiers
The kind names above are not the only stable strings ProcessKit publishes, and the ones that name a
case of a type are not meant to be transcribed out of this page by hand. Those live in the repository as
spec/identifiers.json — a generated, machine readable dictionary of ProcessKit's enum vocabularies,
in the same shape as the ProcessKit-rs crate's own spec/identifiers.json, so a sibling implementation,
a conformance test, or a log pipeline can read one file instead of scraping documentation:
{ "path": "ProcessKit.Outcome", "class": "report_only",
"variants": [ { "variant": "Exited", "identifier": "exited" } ] }
Eight types are published today:
| Type | class | Where the identifier is used |
|---|---|---|
Mechanism | configurable | a name for the case in a configuration file, a log field, or another language's port; the .NET API takes the value itself, so ProcessKit neither writes nor parses this string |
Signal | configurable | the same |
Outcome | report_only | an outcome object's kind, above |
ProcessError | report_only | SupervisionEvent.FailureKind |
LimitVerdict | report_only | each axis of a limit_evidence line, above |
SupervisionEventKind | report_only | SupervisionEvent.Name |
RlimitResource | configurable | the resource name a config-driven caller supplies, which ProcessKit parses back through RlimitResource.TryFromName/FromName; also what Rlimit.ToString() renders (no_file=64:128). One of the two published vocabularies the library reads as well as writes — see Commands → per-process resource limits |
IoPriorityClass | configurable | the Linux I/O scheduling class a config-driven caller supplies, likewise parsed back through IoPriorityClass.TryFromName/FromName; also what IoPriority.ToString() renders (best_effort:7). The level within a class is a number the caller supplies rather than a case, so it is not part of this vocabulary — see Commands → Linux I/O scheduling priority |
Signal.Other is deliberately absent: it carries a raw signal number, whose meaning is the number
itself rather than a name this library could publish.
Two kinds of stable string are deliberately not in the file, because neither names an enum case:
- The report envelope tags —
process_result,process_group_stats,run_profile,member_info,limit_evidence. Each names one shape of report line rather than a case of a type; they belong to this schema, are listed in The schema above, and are frozen on the same terms. - The
processkit.outcomespan and metric labels, an older set with its own spelling ofOutcome.TimedOut(timedout, nottimed_out); see Observability.
Two properties make the file worth pinning:
- It is generated from the live types, and cannot go stale. No identifier is copied into the file:
each is read from the library's own naming function for that type, and the variant list is enumerated
from the live cases themselves. For the four types ProcessKit does emit as text, that is the very
function the emitting code calls — so the
kindthis serializer writes, aFailureKind, an eventName, and the dictionary entry cannot disagree, and a test ties each published identifier back to the string a consumer actually receives.RlimitResourceandIoPriorityClassare tied the other way round, being the vocabularies ProcessKit reads: a test feeds every published identifier back throughRlimitResource.TryFromName/IoPriorityClass.TryFromNameand asserts it returns the case it was published for, so a name taken from this file is always one the builder accepts. One thing this file cannot catch on its own is a brand-new public vocabulary that was never added to it — no match goes non-exhaustive for a type the dictionary has never heard of — so introducing one is a deliberate step in the change that adds it. Adding a union case without an identifier fails the build. Adding aSupervisionEventKindfails the manifest test instead, since F# requires a wildcard arm when matching a .NET enum and the compiler therefore cannot refuse it. Adding any case without regenerating the file fails the test that rebuilds it and compares text, which CI runs as its own step. - Identifiers are additive and frozen. A new variant appends an entry. An identifier that has shipped is never renamed, respelled, or reused for a different variant — that is what makes it safe for a reader in another language to key on, and it is the same promise the field names in the schema above carry.
The dictionary holds identifiers only. It never carries a program name, an argument vector, an environment value, a path, or captured output — nothing from a run reaches it, on any platform.
Secret hygiene
No converter in this feature ever reads captured stdout/stderr content, argv, or environment values — the
same exclusion the logging/tracing seam keeps (see Observability). A ProcessResult
line reports the run — program name, outcome, timings, truncation totals — and leaves the streams to the
caller, who already holds them; a MemberInfo line carries no args/cmdline/env key at all, on any
platform. Every test in ReportJsonTests.fs / ReportJsonTests.cs that plants a token in a captured
stream or a member's argv asserts it never reaches the wire.
AOT / trimming
ReportJson's JsonTypeInfo<'T> properties are built with JsonMetadataServices.CreateValueInfo over
hand-written JsonConverter<'T>s — not System.Text.Json's reflection-based default resolver, and
not a source-generated JsonSerializerContext either: that generator is a Roslyn C# source generator and
does not run against F# projects, which is exactly why this library builds its own metadata by hand
instead. The result is the same guarantee a JsonSerializerContext gives a C# library — safe for a
trimmed or NativeAOT app — reached by the "or equivalent explicit JsonTypeInfo metadata" route.
Versioning
Every one of these report types is [<Sealed>] with an internal constructor and grows fields across
minor releases without breaking this schema's readers. That makes the promise the same one any
self-describing JSONL format needs: a consumer must ignore keys it does not recognize. A field's
spelling and unit, once shipped, are never renamed, repurposed, or given a different unit without a major
release.
Serialize only — deliberately no Deserialize. These are values ProcessKit reports, never values a
caller supplies back to it. Every converter's Read throws NotSupportedException; a
JsonSerializer.Deserialize call against one of ReportJson's JsonTypeInfo<'T> values fails loudly
instead of fabricating a value. Read a JSONL stream generically (JsonDocument /
System.Text.Json.Nodes.JsonNode, or your own DTOs), the same way you would read any external JSONL
format — see Reading a JSONL stream below.
Writing a JSONL stream
F#
task {
match! cmd.OutputStringAsync() with
| Ok result ->
use writer = new StreamWriter("run-report.jsonl", append = true)
do! writer.WriteLineAsync(result.ToReportJson())
| Error error -> fail error
}
C#
var result = await cmd.OutputStringAsync();
if (result is { IsOk: true, ResultValue: var value })
{
await using var writer = new StreamWriter("run-report.jsonl", append: true);
await writer.WriteLineAsync(value.ToReportJson());
}
ToReportJson() returns one compact object with no embedded newline, so appending \n (a plain
WriteLine) after it is always a valid JSONL line. Mixing report types in one file is fine — every line
carries its own "kind", so a reader dispatches on it without knowing which type produced which line.
Reading a JSONL stream
Because the schema is serialize-only, read a line back with System.Text.Json's ordinary
document/element API (or your own record types), dispatching on "kind":
F#
let readReportLine (line: string) : unit =
use doc = System.Text.Json.JsonDocument.Parse line
let root = doc.RootElement
match root.GetProperty("kind").GetString() with
| "process_result" ->
let program = root.GetProperty("program").GetString()
let success = root.GetProperty("success").GetBoolean()
printfn "%s -> success=%b" program success
| "process_group_stats" -> printfn "active=%d" (root.GetProperty("active_process_count").GetInt32())
| other -> printfn "unrecognized report kind: %s" other
C#
void ReadReportLine(string line)
{
using var document = JsonDocument.Parse(line);
var root = document.RootElement;
switch (root.GetProperty("kind").GetString())
{
case "process_result":
var program = root.GetProperty("program").GetString();
var success = root.GetProperty("success").GetBoolean();
Console.WriteLine($"{program} -> success={success}");
break;
case "process_group_stats":
Console.WriteLine($"active={root.GetProperty("active_process_count").GetInt32()}");
break;
default:
Console.WriteLine($"unrecognized report kind: {root.GetProperty("kind").GetString()}");
break;
}
}
A future minor release may add a key to any of these objects; reading by name (GetProperty("kind"), …)
rather than binding the whole line to a frozen shape is what keeps a reader forward-compatible with that.
Next: Dependency injection