Queries and investigations

A recording already has the facts. Queries pull out the part you need. Investigations turn a useful line of inquiry into something you can run again and share.

Try one small project

We will make a game with one changing value, record eight frames, then ask what happened. Create these three files in an empty HealthGame directory.

The manifest is wax.json.

{"name":"Game","version":"0.1.0"}

The application source is Src/App.wax.

static int32 health = 100;

api fn Main() : int32 {
    health = health - 10;
    return health;
}

api fn GetHealth() : int32 {
    return health;
}

The investigation source is Queries/Health.wax. Query source lives in Queries/, which Wax leaves out of normal application builds. You can write the analysis after you make a recording. The queries come first in this file. The investigation below them uses the same HealthState query.

import Frame, FrameRange from Wax::Recording;
import GetHealth from Game;

struct HealthRow {
    int32 health;
    bool critical;
}

struct ReviewArgs {
    int64 frame;
    int64 from;
    int64 span;
}

struct HealthReport {
    int64 frame;
    int32? health;
    int32 observedFrames;
}

query fn HealthState() : HealthRow {
    int32 health = GetHealth();
    return new HealthRow {
        .health = health,
        .critical = health <= 40
    };
}

query fn HealthLow() : bool {
    return GetHealth() <= 40;
}

investigation HealthReview("Review health in a recorded range") {
    /** Review one frame in the context of a recorded range. */
    fn Review(ReviewArgs args) : HealthReport {
        // query.at replays one requested frame. Each named lane is optional
        // because replay may not be able to reach that frame.
        var at = query.at(new Frame(args.frame)) {
            .row = HealthState()
        };
        // query.scan replays every available frame in the requested range and
        // collects each query's typed results under its named lane.
        var scan = query.scan(FrameRange.StartingAt(new Frame(args.from), args.span)) {
            .rows = HealthState()
        };
        int32? health = null;
        if (at.row) |row| {
            health = row.health;
        }
        return new HealthReport {
            .frame = args.frame,
            .health = health,
            .observedFrames = scan.rows.PresentFrames().size
        };
    }

    case "Opening" => Review(new ReviewArgs { .frame = 0L, .from = 0L, .span = 8L });
}

Bind waxdbg to the project and make a short recording.

cd HealthGame
wax debug init
waxdbg recording create run.wxs --frames 8

recording create starts a fresh run of the project bound by waxdbg init. This project has no configured runtime, so waxdbg calls Main once for each requested frame and writes the recorded execution to run.wxs. It does not take a snapshot for every frame. The successful command also selects run.wxs as this session's active recording, so the analysis commands below do not need to repeat its path. Pass a path explicitly when analyzing some other recording, or use waxdbg recording select existing.wxs to select an existing one without moving execution. waxdbg seek existing.wxs selects it and starts a replay.

This command is useful for tests, simulations, command line workloads, and small reproductions that can run on their own. Here it gives us a recording that anyone can recreate without setting up a host. The queries and investigations below work the same way on a recording captured from a full application with real input.

For a debugger run driven by channels, use waxdbg recording input-schema to discover the project’s typed channel envelope, then pass a schedule to recording create --inputs inputs.json. The Inspector guide shows the explicit frameInputs format.

The application begins at 100 health. Main removes 10 during each frame, so frame 0 has health 90 and frame 7 has health 20.

See what queries are available

Ask for the catalog before choosing a query.

waxdbg recording queries

The catalog names both queries and describes their return types. Here is the complete output for this project, formatted across a few lines.

{
  "protocol": "wxdbg.recording-queries.v1",
  "queryRoots": [
    {
      "ownerKind": "application",
      "ownerIdentity": "Game",
      "ownerVersion": null,
      "name": "Game.HealthLow",
      "signature": "() : bool",
      "args": null,
      "returns": { "kind": "bool" },
      "source": { "path": "Queries/Health.wax", "line": 29, "column": 10 }
    },
    {
      "ownerKind": "application",
      "ownerIdentity": "Game",
      "ownerVersion": null,
      "name": "Game.HealthState",
      "signature": "() : Game.HealthRow",
      "args": null,
      "returns": {
        "kind": "struct",
        "name": "Game.HealthRow",
        "fields": [
          { "name": "health", "type": { "kind": "int32" } },
          { "name": "critical", "type": { "kind": "bool" } }
        ]
      },
      "source": { "path": "Queries/Health.wax", "line": 21, "column": 10 }
    }
  ]
}

Game.HealthState is a catalog name. Game comes from wax.json, and HealthState is the query fn name. Commands that select one query use this catalog name exactly.

Read one frame

Run HealthState at frame 4.

waxdbg recording query --frame 4 --name Game.HealthState

The command returns a wxdbg.query.v1 document with the selected queries and frame results. For this one-frame, one-root request, result.frames[0].values[0].value has the shape declared by HealthRow:

{"health":50,"critical":false}

The adjacent present flag distinguishes an absent nullable result from a present value. Omit --frame to evaluate the whole recording, or use --range A:B for inclusive bounds.

A query gives one typed answer each time it runs. It can take no argument, one json value, or one typed struct. Its return type tells the Inspector and other tools how to read the answer.

Run the investigation

An investigation gathers related analysis under one name. Its public functions are choices you can run. A case keeps familiar arguments in the source.

Recordings from apps with host fn imports work here too: replay workers use host results from the tape. The investigation itself is hosted by waxdbg and cannot call the app's host imports. Compilation rejects any such import reachable from its selected member, including through helpers or delegates; query.scan and query.at keep worker execution separate.

List the available investigations, then run Review. It reads frame 4 and scans all eight frames around it.

waxdbg investigation list
waxdbg investigation run --name Game.HealthReview --member Review \
  --args '{"frame":4,"from":0,"span":8}'

For reusable automation, put that same JSON value in a file and pass --args-file review.json. Inline and file arguments use the same published type schema and diagnostics, and cannot be supplied together.

The result wraps the declared HealthReport value with completeness details.

{"protocol":"wxdbg.investigation-run.v1","value":{"frame":"4","health":50,"observedFrames":8},"complete":true,"scans":[]}

query.at asks replay for one frame. Its row field is optional because the requested frame may not be available. The investigation keeps that fact in the nullable HealthReport.health field. If frame 4 cannot be read, the same result shape carries "health":null.

query.scan asks replay for every available frame in a range. Its rows lane collects the typed HealthRow result from each frame. PresentFrames() then counts the frames where that lane returned a value, which gives this report its observedFrames field.

The outer complete value says whether every query.at and query.scan call finished. The scans array describes any known shortfall. Here, complete is true and the array is empty because frame 4 and the full scan range were available.

You can also run the arguments saved by the Opening case.

waxdbg investigation run --name Game.HealthReview --case Opening

Search and scan the recording

recording find runs a query that returns bool and reports matching frames. Ask for every frame where health is low.

waxdbg recording find --name Game.HealthLow --range 0:7 --all
{"protocol":"wxdbg.find.v1","count":3,"coverage":{"requested":{"from":0,"to":7},"evaluated":{"from":0,"to":7},"framesEvaluated":8,"complete":true},"result":{"kind":"matches","matches":[{"frame":5},{"frame":6},{"frame":7}]}}

recording query --columns collects row fields across a range. Select a root with --name, or repeat it to evaluate several roots in one shared replay. Leaving out --name works when the library has exactly one root; otherwise the command lists the candidates.

waxdbg recording query --name Game.HealthState --range 0:7 --columns --text
frame  health  critical
0      90      false
1      80      false
2      70      false
3      60      false
4      50      false
5      40      true
6      30      true
7      20      true

For one root, columns use its returned field names, such as health. In a batch, columns are prefixed with each root's fully qualified name, such as Game.HealthState.health. File names do not determine column names.

To request a reduction instead of the full column table:

waxdbg recording query --name Game.HealthState --range 0:7 --reduce health:min

The projection reports a minimum of 20 across eight present values.

Handle command errors

An unavailable frame produces an error document on standard output and exits with status 1. For this eight-frame recording:

waxdbg recording query --frame 99 --name Game.HealthState
{"protocol":"wxdbg.error.v1","error":{"code":"frame_out_of_range","message":"range [99, 99] is outside recording window [0, 7]"}}

Typed investigation arguments are validated before execution. A string cannot stand in for the integer frame field; this command also exits with status 1:

waxdbg investigation run --name Game.HealthReview --member Review \
  --args '{"frame":"wrong","from":0,"span":8}'
{"protocol":"wxdbg.error.v1","error":{"code":"run_failed","message":"investigation invocation failed: Wax.QueryArgError: argument `frame`: expected an integer, but a string was given"}}

Check the exit status before consuming a result. Human-readable diagnostics may also appear on standard error; keep that stream separate when parsing JSON.

Grow the query library

Typed argument structs can contain arrays, enums, vectors, nullable fields, and nested structs. StableRef<C> carries an object identity using the @<hex> ID shown by the Inspector. Wax checks every argument before the query runs and names a field or array item when a value has the wrong type.

A nullable row leaves a gap in a scan. A List<Row> query can return several entries at one frame. Investigations can use query.scan with several query lanes to recover events, find meaningful windows, or turn a long recording into a small typed report.

The recording created in this walkthrough contains the application source and type information needed for replay, but no query source. The bound Queries/ directory is therefore its default library. An Inspector export can additionally embed its current investigation sources so the exported recording remains portable. Embedded sources replace the disk library at the same logical path; --file and --query-dir replace either. --no-query-library excludes only the library on disk, not an embedded snapshot or sources you name explicitly.

By default, a query batch runs inside one isolation bracket per frame. Roots run in the requested order, so later roots can see earlier roots' mutations in that frame. Temporary allocations and mutations are rolled back after the batch; they do not reach the next frame. --retain-mutations explicitly keeps those changes across frames and requires one worker. The JSON column table reports whether isolation used an undo journal, a snapshot, or both.

Open this result in the Inspector

Open the recording on HealthReview, then choose the Opening case.

waxdbg investigation open run.wxs --name Game.HealthReview

The waxdbg guide covers live debugging, recordings, and the Inspector. The agent integration guide explains how an agent can join the same Inspector and work with the same evidence.