ProtoTrace
ProtoTrace is ProtoTest's execution trace. Because ProtoTest coordinates the lifecycle and understands its integrations, it can record what happened in every test — hooks, attributes, clients, state, requests, browser actions, assertions, attachments, cleanup — without a single logging line in your tests. At the end of the run, everything is written to one portable .prototrace file.
When a test fails in CI, you download that file and open it in the ProtoTrace viewer. You see the failing assertion in context: which user was set up, what the request looked like, what came back, what the browser showed.
It's on by default
builder.ConfigureTracing(trace =>
{
trace.Enabled = true; // default
trace.OutputPath = "TestResults/billing.prototrace"; // default: TestResults/prototest-{runId}.prototrace
});
With Enabled = false, nothing is recorded and no file is written. Observations and reports keep working.
What a trace contains
A run contains tests, and a trace records two things about each of them:
- What ran — a tree of operations (spans): each has a duration and an outcome — a request, a flow, a hook. Operations nest: a
web.flowcontains its clicks, a test's execution contains everything the test body did. Moments inside an operation — a server starting, a subscription message — are events on it, and so are the observations, attachments and findings it produced. - What existed and changed — the state: every client, context, resource and tracked value, with its state at the end and a trail of changes. Each change names the operation that caused it, and where the value came from: the test itself, a response it observed, or the application's own instrumentation.
Tracked values are items with kind value and an id of the form {type}:{identity}. Test-side provisioning writes the result type in snake_case as the type segment — InvoiceLine becomes invoice_line, so the item reads invoice_line:42 — and {identity} is what the provisioner returned. An application's own instrumentation writes the prefix of its identity-shaped attribute instead: invoice.id = 42 contributes invoice:42. The two are the same item only when the attribute prefix matches the type segment and the values match, so name a type's identity attribute after the type (invoice_line.number) to correlate them.
Every entry belongs to a phase:
| Phase | |
|---|---|
Setup | hooks and attributes before the test body |
Execution | the test body |
Teardown | hooks, attributes and disposal afterwards |
Rollback | teardown after a failed setup |
Run | run-level work |
…and ends with an outcome: Succeeded, Failed, Partial, Cancelled, Skipped or Unknown.
Partial
A test can pass while something inside it failed — a diagnostic step that isn't allowed to fail the run, a best-effort capture. That test is recorded as Partial rather than green, so it doesn't hide in a sea of passing tests.
Entries you'll see
A few of the kinds recorded automatically:
| Kind | From |
|---|---|
test.setup, test.execution, test.teardown, test.rollback | the lifecycle |
client.initialize, client.resolve | clients — initialization, and a lookup that failed |
context.resolve | typed state — a failed lookup; SetContext is a state change on the context entity, not an entry |
attachment.publish, and the observation / attachment / finding records on an operation | attachments, observations and findings |
auth.outcome (applied / skipped) | HTTP authentication, recorded on the request operation itself |
auth.handler.apply | each handler of a composite authenticator |
assert.json.shape | shape assertions — REST, GraphQL, gRPC and messaging: expected, actual and matched properties |
assert.http.status, assert.grpc.status | status assertions |
grpc.call, grpc.client.resolve, grpc.attachment.failed | the gRPC client — calls, fallback resolution and capture failures |
messaging.publish, messaging.await, messaging.attachment.failed | publishing and awaiting messages |
web.navigate, web.click, web.flow, web.login, assert.web, … | the browser |
web.page.visited, web.page.verified, web.page.available | page coverage — observations, not operations: the pages a journey reached, checked and could reach |
data.build, data.build_many, data.create, data.create_many, data.explain | building test data |
data.provision, data.cleanup, data.value.resolve | provisioning and cleanup |
assert.sheets | sheet, range and table assertions — expected and actual values |
sql.connection.open, sql.transaction.begin, sql.transaction.rollback, sql.enlist | the SQL connection lifecycle |
aspnetcore.server.initialize | the in-process server, carrying aspnetcore.application.type, aspnetcore.server.lifetime, aspnetcore.server.reused, aspnetcore.web_host.customized and aspnetcore.client.customized |
The in-process server is also a state entity with id server:{type} — {type} is the entry point's full name, as in server:Northstar.Api.Program — and those aspnetcore.* attributes are its state.
Sensitive values stay out: form fills are recorded by length, headers and JSON properties are redacted using the same rules as attachments, and sensitive query parameter values are redacted in HTTP request URLs and web navigation addresses.
Trace vs. observations
They look similar and answer different questions:
- Trace — automatic. What did ProtoTest do?
- Observations — intentional. What did the test learn? Coverage facts, measurements, findings.
They share correlation, but they're kept separate: observations feed reports, the trace feeds the viewer.
Viewing a trace
The ProtoTrace viewer is a static web app. Trace files are read entirely in your browser and never uploaded. To look around before you have a trace of your own, open the sample trace: a run of the demo suite, with a failing test and two partial ones.
- The run opens with its verdict, what needs attention — failing and partial tests with the check that decided them, findings, gates — and what the run could see: where the application ran, which capabilities were composed, and which sources of values were present.
- A failing test leads with its failure: the check that failed, expected against actual for every property, and the call it judged.
- Story tells the test phase by phase: each call carries its checks, and the framework's own steps fold away until you open them.
- State shows every tracked item with its lifeline and changes; select a change to jump to the operation that made it.
- Spans is the complete, searchable tree.
- The inspector shows everything one operation recorded — where in your code it started, request and response, JSON as a collapsible tree, the shape a check validated, what it changed — and every item's change trail. The address holds the selection, so a link opens the same place.
The viewer's source is in the repository under viewer/ if you'd rather host it yourself.
Where in the code
Every operation your suite starts — a request, a check, a browser step, a gRPC call — records where in your code it started: the file, the line and the method, as the OpenTelemetry attributes code.file.path, code.line.number and code.function.name. The inspector shows that line with the code around it, so a failed check points at the line that made it.
- The location comes from the stack and your test project's symbols, which the .NET SDK writes by default. ProtoTest's own lifecycle — setup, teardown, the test's execution — records none.
- Inside a git repository the path is relative to its root (
tests/Orders.Tests/OrderTests.cs), so it reads the same on every machine and does not carry your local directory layout. - The trace embeds each source file a location points at, so the viewer can show the code without access to the repository.
builder.ConfigureTracing(trace =>
{
trace.CaptureSourceLocations = false; // no locations, and so no embedded code
trace.EmbedSources = false; // locations only, no code in the file
});
Turn EmbedSources off when a trace goes to people who should not read the suite's code.
The file format
A .prototrace file is a ZIP archive:
run.prototrace
├── manifest.json { "formatVersion": "2.0", "spansEntry": "spans.json", "stateEntry": "state.json" }
├── spans.json what ran: one resource group per test and one for the run
├── state.json what existed and changed: tracked items with their changes
├── sources/1/OrderTests.cs the code an operation's location points at, when embedded
└── resources/
├── {testId}/artifact-1/rest-01-response.json
├── {testId}/artifact-2/playwright-default-trace.zip
└── run/HtmlReportSink/run-artifact-1/report.html
spans.jsonholds a resource group per test — its id, name, class, method, outcome and duration — with its operations, their events, and the artifacts the test declared. The run's own group carries its id, start and end, and the environment it ran in (environment.runtime,environment.os, …); run-level events such as gate verdicts sit on it too.state.jsonholds the run's tracked items and each test's: kind, id, name, scope, first and last seen, the state at the end and every change, with the operation that caused it.sourcesin the manifest maps each recordedcode.file.pathto its embedded copy.- Each artifact is declared once, with its media type, size and path in the archive; an attachment event refers to it by id.
Entries are stored uncompressed, so a browser can read the archive without a decompression library. Property names are camelCase. The archive manifest is format 2.0, its spans document is 2.0, and its state document is 1.1 — tracked values are generic value items with the domain type in the id. The live snapshot exposed to code (host.Trace.Snapshot()) reports format 1.9.
Test artifacts — every attachment — live under the test's id. Run-level artifacts, such as the reports written by sinks, live under resources/run/.
Reading a trace in code
The host exposes a live snapshot, which is how ProtoTest's own tests assert on tracing:
var run = host.Trace.Snapshot();
var click = run.Tests.Single().Entries.Single(entry => entry.Kind == "web.click");
Assert.That(click.Outcome, Is.EqualTo(ProtoTraceOutcome.Succeeded));
To add your own entries, see Extending ProtoTest. To forward operations to an observability backend, see OpenTelemetry.