Logging simulation diagnostics

If you want to save periodic diagnostic records to a file, you can configure a file handler for the passengersim.diagnostics logger. This example shows how to do that, and how to read the resulting JSON-lines file. It is important to remember that the logger must be configured before the simulation run starts, otherwise your periodic records will simply be sent into the void, never to be seen.

PassengerSim uses the passengersim.diagnostics logger and existing handlers; it does not create a log file for you. If that logger does not accept INFO records when the run starts, periodic diagnostic logging is not started.

This example writes one JSON object per line to a uniquely named file, runs a small two-trial model, and reads the resulting records. Logging is independent of which live dashboard panels are visible.

The usual PassengerSim installation and license requirements apply. See periodic diagnostic logging for the full configuration and record descriptions.

import json
import logging
from datetime import UTC, datetime
from pathlib import Path
from uuid import uuid4

import passengersim as pax

Configure a short demonstration run

The default logging interval is 60 seconds. Here we use one second to make periodic records easier to observe during a short example; a very short run may still finish in less than one second, before the first periodic record. Start, completion, and other lifecycle records are emitted regardless of the interval. For production runs, 60 seconds is usually a better starting point.

Temporary SQLite recording lets the logs include database sizes and growth without keeping the database after the result is closed. The diagnostic log file itself remains available.

cfg = pax.Config.from_yaml(pax.demo_network("3MKT/DEMO"))
cfg.simulation_controls.num_trials = 2
cfg.simulation_controls.num_samples = 500
cfg.simulation_controls.burn_samples = 50
cfg.simulation_controls.diagnostics = {"mode": "live", "log_interval": 1.0}
cfg.db = {"result_storage": "temporary", "write_items": ["leg"]}
cfg.outputs.disk = False
cfg.outputs.reports.clear()

Install the file handler before running

Each diagnostic LogRecord that PassengerSim will emit has a diagnostics dictionary. This formatter saves that dictionary without rounding its numeric values and adds a UTC timestamp. The filter keeps unrelated messages out of the JSON-lines file. Other than knowing that there will be a diagnostics dictionary in the LogRecord, nothing in the following code block is PassengerSim-specific, it is just regular Python logging semantics.

class DiagnosticJSONFormatter(logging.Formatter):
    """Serialize diagnostic records with an explicit UTC timestamp."""

    def format(self, record: logging.LogRecord) -> str:
        payload = dict(record.diagnostics)
        payload["timestamp"] = datetime.fromtimestamp(record.created, UTC).isoformat()
        return json.dumps(payload)


log_directory = Path("diagnostic-logs")
log_directory.mkdir(parents=True, exist_ok=True)
log_path = log_directory / f"run-{uuid4().hex[:12]}.jsonl"

diagnostic_logger = logging.getLogger("passengersim.diagnostics")
previous_level = diagnostic_logger.level
previous_propagate = diagnostic_logger.propagate
file_handler = logging.FileHandler(log_path, encoding="utf-8")
file_handler.setLevel(logging.INFO)
file_handler.setFormatter(DiagnosticJSONFormatter())
file_handler.addFilter(lambda record: hasattr(record, "diagnostics"))

diagnostic_logger = logging.getLogger("passengersim.diagnostics")
diagnostic_logger.addHandler(file_handler)
diagnostic_logger.setLevel(logging.INFO)
diagnostic_logger.propagate = False

Once that’s all set up, we are ready to actually run a simulation, and it will log diagnostics while it’s running.

The try/finally block closes the file and restores the logger configuration even if the simulation fails. Removing only the handler installed here also prevents duplicate handlers when the notebook cell is rerun. If you want to re-run with logging, you’ll need to re-run both cells to re-set the handlers for this run. Other existing handlers are left in place; propagation is temporarily disabled to keep these records out of ancestor loggers’ console handlers.

try:
    # The handler and INFO level are set in the prior cell before constructing/running the model.
    with pax.MultiSimulation(cfg) as sim:
        result = sim.run(max_processes=2)
        try:
            final_diagnostics = result._metadata["diagnostics"]
        finally:
            result.close()
finally:
    # Clean up the diagnostic logger and file handler.
    diagnostic_logger.removeHandler(file_handler)
    file_handler.close()
    diagnostic_logger.setLevel(previous_level)
    diagnostic_logger.propagate = previous_propagate

print(f"Diagnostic records saved to {log_path.resolve()}")

╭────────── Summary revenue statistics · 3MKT ──────────╮
│                           AL1               AL2       │
│ Run    Samples     Mean  ~95% CI     Mean  ~95% CI    │
│ 3MKT       900   90,148     ±810   90,117     ±842    │
│ Post-burn-in only · Approx. 95% CI for mean           │
╰───────────────────────────────────────────────────────╯
Diagnostic records saved to /Users/jpn/Git/pax/documentation/examples/diagnostic-logs/run-0ac46e29da94.jsonl

Inspect the log

The last record contains the final available snapshot and completion status. Timing values are summed worker seconds, which can exceed elapsed wall time for parallel runs. Carrier counts exclude burn-in; completed_samples includes it. Confidence-interval half-widths are null until 30 post-burn-in observations are available for a carrier.

Periodic records can contain older worker observations during a long operation; inspect snapshot_age_seconds and the ages in active_trials when interpreting them. Database sizes include each main file and its journal/WAL/SHM companions. Files may disappear as temporary databases are consolidated or cleaned up.

Loading the whole file below is convenient for this small example. For long runs, iterate over lines instead of retaining every record in memory.

with log_path.open(encoding="utf-8") as stream:
    records = [json.loads(line) for line in stream]

final_record = records[-1]
print(f"Recorded {len(records)} diagnostic events")
print(f"Final status: {final_record['event']}")
print(f"Completed samples: {final_record['completed_samples']}")
print(json.dumps(final_record, indent=2))
Recorded 11 diagnostic events
Final status: completed
Completed samples: 1000
{
  "event": "completed",
  "run_id": "3MKT:476c044c5115",
  "trial_id": null,
  "elapsed_wall_seconds": 3.2361356250003155,
  "snapshot_age_seconds": 5.2458999562077224e-05,
  "completed_samples": 1000,
  "samples_per_second": null,
  "stage": null,
  "active_trials": {},
  "worker_seconds": {
    "core": 1.7754908331171464,
    "rm": 0.8861150727443601,
    "database": 0.32095987120919744,
    "other": 0.7070784309289593
  },
  "worker_total_seconds": 3.689644207999663,
  "worker_percent": {
    "core": 48.120922588352414,
    "rm": 24.016274274444648,
    "database": 8.698938247577143,
    "other": 19.1638648896258
  },
  "recent_worker_seconds": {
    "core": 0.0,
    "rm": 0.0,
    "database": 0.0,
    "other": 0.0
  },
  "recent_worker_percent": {
    "core": null,
    "rm": null,
    "database": null,
    "other": null
  },
  "carriers": {
    "AL1": {
      "samples": 900,
      "mean": 90148.08333333333,
      "ci95_half_width": 809.9028851655281
    },
    "AL2": {
      "samples": 900,
      "mean": 90116.80555555558,
      "ci95_half_width": 841.043068733584
    }
  },
  "databases": [
    {
      "path": "/private/var/folders/sb/mb_3f0t16lx0wm3tr8r5g9rm0000gn/T/passengersim-db-8hvil4nb/trial-0000.sqlite",
      "bytes": 2216488,
      "bytes_per_second": 2090591.6044273435,
      "error": null
    },
    {
      "path": "/private/var/folders/sb/mb_3f0t16lx0wm3tr8r5g9rm0000gn/T/passengersim-db-8hvil4nb/trial-0001.sqlite",
      "bytes": 2237952,
      "bytes_per_second": 2111946.5066948407,
      "error": null
    }
  ],
  "failed_trials": {},
  "cancelled_trials": [],
  "database_timings": {
    "1": {
      "simulation_and_writes_s": 1.8471169589993224,
      "eager_reports_s": 0.00023237499954120722,
      "worker_finalization_s": 0.0004215830012981314,
      "receive_delay_s": 0.004592416999003035,
      "merge_s": 0.009864333000223269
    },
    "0": {
      "simulation_and_writes_s": 1.8506013340011123,
      "eager_reports_s": 0.00015487499877053779,
      "worker_finalization_s": 0.0003845000010187505,
      "receive_delay_s": 0.009901082999931532,
      "merge_s": 0.01187716700042074
    }
  },
  "timestamp": "2026-11-04T05:40:30.169700+00:00"
}

Adjust logging for the next run

Use cfg.simulation_controls.diagnostics.log_interval = 60 for the default cadence, or set it to None to disable diagnostic logging while retaining other diagnostics. Setting cfg.simulation_controls.diagnostics.mode = "off" disables both collection and diagnostic logging. Changing .mode back to "live" keeps the logging interval and panel settings.

Assigning a shorthand to the entire diagnostics setting, such as "full" or "off", resets its other options, including the interval. Set the interval afterward if needed. For another run with file logging, rerun both the handler and run cells above so logging is configured again before the simulation starts.