Skip to main content

ProtoTrace

ProtoTest records each test on its own. No logging calls are needed. The trace holds hooks, requests, checks, state changes, attachments and cleanup, and the run writes it to one portable .prototrace file. Portable has a limit: a reader only opens an archive from its own era, so check format compatibility before you archive traces long term.

When a test fails in CI, download that file and open it in the ProtoTrace viewer. You see the failing check with the request, the response and the setup around it. Without a browser, read the same story from a terminal:

dotnet tool install --global ProtoTest.Cli
prototest summary TestResults/prototest-{runId}.prototrace
ProtoTest trace 2.0 · run bb2dd8b330924038894c161efda1e5f4 · 2026-09-29 18:36:55Z - 2026-09-29 18:36:58Z
1 tests · 1 failed

FAILED Northstar.ProtoTest.FailureDrills.TheAddressWasHardcodedForOneMachine (2.60 s)
ConnectionError reaching http://127.0.0.1:5099: connection refused.
test.execution Test execution · failed
cause: runner-reported failure

That is a committed failing run of the Learning track. Run ids and timestamps are new on every run; compare the shape, not the values. Open the sample trace to look around before you have one of your own, and read the archive format for what is inside.

What it is​

Tracing is on by default:

builder.ConfigureTracing(trace =>
{
trace.Enabled = true; // default
trace.OutputPath = "TestResults/billing.prototrace"; // default: TestResults/prototest-{runId}.prototrace
trace.EmbedArtifacts = true; // false declares attachments without their bytes
});

With Enabled = false, no trace file is written, application spans from the configured sources are not captured, and the in-memory recorder drops operations and records (observations, attachments, findings and tracked values). The run snapshot still lists each test with its outcome, and the reports keep working: run gates read report items, not the operation tree.

The trace is not the same thing as observations. The trace is automatic and answers "what did ProtoTest do?". Observations are facts a test chose to record. They share correlation, but observations feed the reports and the trace feeds the viewer.

How to read it​

In the viewer​

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 sample suite, with four failing tests and one partial one.

  • The run leads one test list with shared search and outcome filters, and splits into views. Overview holds Needs attention, named with the diagnosis rule (Assertion, Operation error, Runner failure or Finding, in the precedence prototest summary uses), beside what the run could see. Timeline places tests and their gaps on the run clock. Operations lists the run's own work and tracked items, which open in the inspector. Details lists the run id and every environment.* value, and Files everything the run attached. A view with nothing in it has no tab; #/run/<view> links to one.
  • A failing test starts with a verdict bar: the diagnosis rule, the check or operation that decided it, the call it judged, and the first difference. The inspector holds the full comparison.
  • Steps opens on the test body. Setup and teardown fold into summary rows and open along a failure. Calls carry their checks; time with no recorded operation gets its own row.
  • Timeline is every operation on the test clock. Zoom to one phase, search by name, kind or attribute, and filter for needs attention. Matching operations keep their ancestors for context.
  • State shows tracked items with lifelines and changes on that same clock. Select a change to open its operation, shade its time and highlight the items it touched. Select an item to see its trail; its operations are highlighted in Timeline.
  • Evidence brings observations, files, findings and moments into time order. Each names the operation that recorded it, or states that none was above it.
  • The inspector shows the source, request and response, comparisons, state changes and evidence. Moments include their attributes and sections, observations their metadata, and findings their category, tags, target and metadata. A section index jumps to each part. Binary bodies recorded as text are marked instead of shown as broken glyphs.

The Framework switch beside the view tabs shows, dims or hides the framework's own operations (hooks, extensions, clients, resources) in Steps and Timeline, and the machinery a test ran on in State. Dim is the default, the choice is remembered, and a framework operation that failed always stays.

The header follows the path Run > Test > Operation. Test views use #/test/<id>/steps|timeline|state|evidence; old story, spans and files links still open the corresponding view. The URL holds the selected operation or item, including selections on the run, so a shared link keeps that place.

Hatched time means no operation was recorded, not that nothing happened. The viewer derives gaps inside a phase from 15% of its duration, at least 20 ms, and always from 250 ms. Test 12 in the sample has a one-second real wait; test 15 waits for an address that cannot be reached. Both become visible gaps without changing the trace format.

These excerpts follow test 12 from the run through its four views and into the failing check:

ProtoTrace viewer · recorded previewprototest-demo.prototrace

4 failed, 1 partial. Start with test 08, then 10.

4 failed, 1 partial, 14 passed

19 tests in 4.06 s29 Sep 2026, 18:37:26 UTC.NET 8.0.31 on Microsoft Windows 10.0.26200ProtoTest demo trace

Needs attention
08A bare status hides what the application saidAssertion Assert status · 201 CreatedExpected HTTP status 201 (Created), but received 400 (BadRequest).
10An unknown project ID is treated as mineAssertion Assert status · 200 OKExpected HTTP status 200 (OK), but received 404 (NotFound).
12A real wait does not close the due windowAssertion Assert response shape$.status: expected "past_due", got "active"
15The address was hardcoded for one machineRunner failure Test executionConnectionError reaching http://127.0.0.1:5099: connection refused.
11A passing journey can still carry a warningFinding The create response carried 4 fields no assertion mentioned: createdAtUtc, environmentCount, id, slug.Warning, Coverage
11The create response carried 4 fields no assertion mentioned: createdAtUtc, environmentCount, id, slug.Warning finding Coverage
Gateno error findingsPassed No error findings were recorded.
What this run could see
ApplicationIn-process
CapabilitiesPlaywrightDataSheetsGraphQLRESTASP.NET CoreSQL
Values fromTest sideObserved (not visible)Application
Open this exact run in the viewer → Copied from prototest-demo.prototrace, shown as the viewer shows it.

Open your own archive​

A run leaves its archive at TestResults/prototest-{runId}.prototrace under the test project's output folder, or at the path you set with trace.OutputPath. From there:

  1. Run the suite once so the file exists.
  2. Open it in the viewer: drop the file on the page, or press Open trace and choose it. The file is read in your browser and never uploaded.
  3. Without a browser, read the same story from the terminal:
prototest summary TestResults/prototest-{runId}.prototrace
prototest index TestResults

prototest summary lists the run, the outcome counts and every test that did not fully pass, with its error, source location and the failing operation. prototest index writes one static index.html beside the runs: each run's outcome counts, its failing tests, links to its trace and digest, and every archive that could not be read. The page is one file beside the traces, so a folder of evidence can be shared without a server. Both verbs are the ProtoTest.Cli tool; install it with dotnet tool install --global ProtoTest.Cli.

Run the viewer locally​

The hosted viewer is the same static app as the repository's viewer/ folder, so an offline or air-gapped setup runs it without the trace ever leaving the machine:

cd viewer
npm ci # once, where the registry is reachable
npm run build # writes the static dist/ folder
npm run preview # serves dist/ locally

Then open the served page and drop the .prototrace archive on it, or press Open trace and choose it. The file is read in browser memory and never uploaded, exactly like the hosted viewer. For a closed network, build once where npm ci reaches the registry and carry the dist/ folder over: it needs no server runtime, so any static file server in front of it works. The archive to open is the run's file at TestResults/prototest-{runId}.prototrace, or the path trace.OutputPath set. npm run dev instead starts the development server when you work on the viewer itself.

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, so a web.flow contains its clicks and a test's execution contains everything the 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.

One test, one operation and its records, the shape every section of a trace repeats:

test.execution Execution · Succeeded · 314 ms
├── data.provision Provision invoice · Succeeded
│ └── state change: invoice:42 created by the test
├── http.request GET /api/orders/42 Succeeded
│ ├── assert.http.status 200 Succeeded
│ └── assert.json.shape 3 properties Succeeded
└── events on test.execution
├── observation invoice.state = paid
└── attachment 00001-rest-01-response.json

Every entry belongs to a phase:

Phase
Setuphooks and attributes before the test body
Executionthe test body
Teardownhooks, attributes and disposal afterwards
Rollbackteardown after a failed setup
Runrun-level work

Every entry also ends with an outcome: Succeeded, Failed, Partial, Cancelled, Skipped or Unknown.

A test can pass while something inside it failed (a diagnostic step that is not allowed to fail the run, a best-effort capture). That test is recorded as Partial, not Passed.

Tracked values are items with kind value and an id of the form {type}:{identity}. Test-side provisioning uses the result type in snake_case as the type segment. InvoiceLine becomes invoice_line, and the item reads invoice_line:42. The 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.

A few of the entry kinds recorded automatically:

KindFrom
test.setup, test.execution, test.teardown, test.rollbackthe lifecycle
client.initialize, client.resolveclients: initialization, and a lookup that failed
context.resolvetyped 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 operationattachments, observations and findings
auth.outcome (applied / skipped)HTTP authentication, recorded on the request operation itself
auth.handler.applyeach handler of a composite authenticator
assert.json.shapeshape assertions in REST, GraphQL, gRPC and messaging: expected, actual and matched properties
assert.http.status, assert.grpc.statusstatus assertions
grpc.call, grpc.client.resolve, grpc.attachment.failedthe gRPC client: calls, fallback resolution and capture failures
messaging.publish, messaging.await, messaging.attachment.failedpublishing and awaiting messages
web.navigate, web.click, web.flow, web.login, assert.web, ...the browser
web.page.visited, web.page.verified, web.page.availablepage coverage: observations, not operations, for the pages a journey reached, checked and could reach
data.build, data.build_many, data.create, data.create_many, data.explainbuilding test data
data.provision, data.cleanup, data.value.resolveprovisioning and cleanup
assert.sheetssheet, range and table assertions: expected and actual values
sql.connection.open, sql.transaction.begin, sql.transaction.rollback, sql.enlistthe SQL connection lifecycle
aspnetcore.server.initializethe 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}:{application} ({type} is the entry point's full name, as in server:ProtoTest.SampleApp.Program:Northstar), and those aspnetcore.* attributes are its state.

ProtoTest's own capture redacts by name: 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. Findings, observations and attachments you record yourself are redacted only where you mark them sensitive, so treat a trace like test output.

A suite can name more values sensitive. ConfigureRedaction adds names to the defaults, and state values and finding metadata redact them:

builder.ConfigureRedaction(redaction => redaction.AddSensitiveName("OwnerToken"));

The names travel with the host: a second host in the same process keeps the defaults only. ProtoTest:Redaction binds the same names from configuration. See Configuration for which source wins. Attachment and diagnostic JSON keeps its own per-protocol list (SensitiveJsonProperties on each protocol's attachment options), so a name added here reaches state values and finding metadata, not those bodies.

A walk through one test​

The sample's TheTestClockClosesTheDueWindow was recorded with one of each layer in it. The walk below reads that trace layer by layer, including the parts the same file cannot show.

One test, layer by layerFailureDrills.TheTestClockClosesTheDueWindow
  1. Runbefore the first test

    What the run composed, and where the application ran. This is what the viewer shows on the run screen.

    Recorded operations (4)
    • REST, GraphQL, ASP.NET Core, SQL, Data, Sheets, Playwrightcapability · the capabilities the composition declared
    • ProtoTest.SampleApp.Programserver · in-process, lifetime PerRun
    • Northstar web on a loopback listenerapplication · readiness /health, 1 attempt, waited 84 ms
    • .NET 8.0.31 on Windows 10.0.26200, X64environment
  2. Setup508.4 ms

    Everything before the test body: hooks, attributes, clients, the database connection and the state the test asked for.

    Recorded operations (5)
    • Setuptest.setup · 508.4 ms
    • Rest, GraphQL, and the web clientclient.initialize · each client initializes once, with its address recorded
    • Open SqliteConnectionsql.connection.open · 0.05 ms
    • Application, NorthstarTenantattribute.before · the tenant attribute provisions a TenantResponse in 151.7 ms
    • SignedInAs, NorthstarMemberattribute.before · the sign-in is recorded as state, not as a log line
  3. Execution286.8 ms

    The test body. New entries nest under test.execution; the clock move is an event on it.

    Recorded operations (10)
    • Test executiontest.execution · 286.8 ms, succeeded
    • Create · IssueInvoiceRequestdata.create · 165.8 ms
    • Provision · IssueInvoiceRequest to InvoiceResponsedata.provision · 164.5 ms
    • Clock advanced by 8:0:00:00clock.advance · event on test.execution, from the test side
    • invoice.issueNorthstar.Domain · reported by the application itself
    • REST · GET /api/v1/organizationhttp.request · 65.3 ms
    • Assert status · 200 OKassert.http.status · the check that decided the request
    • Assert response shapeassert.json.shape · 7.5 ms, the property the test depended on
    • REST · POST /api/v1/invoices/{invoiceId}/payhttp.request · 35.0 ms
    • Assert response shapeassert.json.shape · the paid status
  4. Teardown40.6 ms

    What the test leaves behind, and what the run releases for it. The trace records the releases, so a leaked resource would show here.

    Recorded operations (4)
    • Teardowntest.teardown · 40.6 ms
    • 5 REST artifacts and the scenario summaryattachment.publish · request, response and expected shape for each call
    • Cleanup · TenantResponsedata.cleanup · 8.9 ms, the provisioned tenant is removed
    • Application services, database connection, messaging consumerresource.release · released in order, each with its own release time

What this trace cannot see

Test sideObservedApplication

The run recorded values from the test side and from the application itself. Nothing was recorded as observed in a response, so the trace does not claim to have seen a value the test did not read. These are the edges of that picture.

  • Work outside the composition

    The environment drill used a raw HttpClient against a fixed address. Its test.execution span ran 2.05 s and recorded no operation at all; the entry holds the connection failure, and the call it never wrapped cannot appear in the trace.

  • Work inside the application

    invoice.issue and invoice.pay are there because the demo application reports that activity source. A step the application does not report has no span, however much it did.

  • Wall time

    The clock item records the delta and the moment the clock stands at. It cannot tell you how long anything would have taken on the real clock.

Read from the test that moves the clock instead of waiting in docs/static/lessons/l0-time-fix.prototrace. The viewer draws the same trace from that archive (download it and drop it on the viewer).

The same journey, two ways​

The sample suite runs four deliberate failures next to the tests that do the same journey the right way. Pick a pair to compare what the trace recorded in each run.

Who moves the clock?

The drillfailed
ARealWaitDoesNotCloseTheDueWindow1.25 s
Recorded operations (3)
  • data.createCreate · IssueInvoiceRequestsucceeded152.2 ms, provisioned in the test tenant
  • http.requestREST · GET /api/v1/organizationsucceeded74.0 ms, HTTP 200
  • assert.json.shapeAssert response shapefailedShape mismatch failed with 1 error(s): • [$.status]: Values did not match. (Expected: "past_due", Actual: "active")
Download this run's archive
The test that holdssucceeded
TheTestClockClosesTheDueWindow286.8 ms
Recorded operations (6)
  • clock.advanceClock advanced by 8:0:00:00succeededrecorded on test.execution, from the test side
  • data.createCreate · IssueInvoiceRequestsucceeded165.8 ms, provisioned in the test tenant
  • http.requestREST · GET /api/v1/organizationsucceeded65.3 ms, HTTP 200
  • assert.json.shapeAssert response shapesucceededthe same property, now past_due
  • http.requestREST · POST /api/v1/invoices/{invoiceId}/paysucceeded35.0 ms, HTTP 200
  • assert.json.shapeAssert response shapesucceededstatus paid
Download this run's archive

What changesMove the test clock instead of waiting on real time.

Every name, duration and message is from a recording of samples/Northstar.ProtoTest with the drills enabled. The drill failed on purpose; the test beside it runs the same journey and passes. Each pane links that test's own archive.

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, 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. EmbedSources and EmbedArtifacts default on, so turn them both off and scope response capture (CaptureResponses) when the trace leaves your trust boundary.

To read a run from code instead of the viewer, see Extending ProtoTest. To forward operations to an observability backend, see OpenTelemetry.

ProtoTraceDiscovery.Discover(folder) lists the readable runs a folder holds, newest first, and names the archives it had to skip. It is the scan the prototest CLI and the MCP server share.

Without a browser, ProtoTest.Traces reads the archive and the prototest CLI prints the same story: summary and index cover one archive and a folder of runs. A coding agent reads the same story through the MCP server. See Agent workflows.

Limits​

The limitWhen it matters
Redaction covers ProtoTest's own captureYour own attributes and attachments can still carry application data, so treat a trace like test output
Untraced gaps are derived by the viewer, not stored on the wireA gap identifies time without recorded operations; it does not diagnose what happened during that time
Binary-body marking detects replacement characters in recorded textOpen the attached file for its bytes; marking does not repair capture or change the archive
CaptureSourceLocations is the largest tracing costEmbedSources controls whether the archive carries the code the viewer shows; the benchmarks page records what a trace costs at 100 and 1,000 tests and the levers that change it
The archive, its versions and its lifetimeThe .prototrace archive: format, compatibility, run metadata and what survives a killed process

Learn more​