ESC
Type to search...
S
Soli Docs

Debugging & Breakpoints

Learn how to use soli's powerful debugging tools including breakpoints, interactive REPL, and the development error page.

Breakpoints

Use the debug() function to pause execution and open the interactive debug page. This is useful for inspecting variable state and understanding program flow.

Setting Breakpoints

def process_user(user_id: Int) -> Hash
  user = database.find(user_id)
  # Set a breakpoint to inspect the user object
  debug()
  if (user == null)
    return {"error": "User not found"}
  end

  profile = enrich_profile(user)
  # Another breakpoint to see enriched data
  debug()
  profile
end

Why Use Breakpoints?

Inspect State

Examine variable values and data structures at any point in your code

Trace Execution

Follow the program flow step by step to understand logic

Evaluate Code

Run arbitrary soli code to test hypotheses or calculate values

Debug Errors

Automatically triggered on errors to help diagnose issues

Tip

Breakpoints only work in development mode. In production, debug() calls are ignored.

This builtin was previously named break(). It was renamed because break is now a loop keyword — see Control Flow.

Debug Page

When a breakpoint is hit or an error occurs, soli displays a comprehensive debug page with multiple panels for investigation.

Debug Page Features

# Interactive REPL - Execute soli code in breakpoint context
# Stack Trace - Navigate through call stack frames
# Source Viewer - View source code with line highlighting
# Request Inspector - Examine request params, query, headers, body
# Environment Info - View time, method, path of the request

Stack Trace Navigation

Click on any stack frame to view the source code at that location. The source viewer shows the relevant code with the error line highlighted.

Stack Trace:
 0. process_user at ./src/users.sl:15
 1. handle_request at ./src/routes.sl:42
 2. main at ./src/main.sl:8

Click on any frame to see the source code.

Source Code Viewer

The source viewer displays code around the breakpoint or error location with:

  • Line numbers for easy reference
  • Highlighted error/breakpoint line
  • Context lines above and below
  • Syntax highlighting

Interactive REPL

The debug page includes an interactive REPL that runs in the context of your breakpoint. You can execute any soli code to inspect and manipulate data.

The REPL endpoint is token-protected and accepts loopback clients by default. If your *.test domains point to a trusted local server on another machine, start dev mode with SOLI_DEV_REPL_ALLOW_REMOTE=1 and pin the token to a secret you control via SOLI_DEV_REPL_SECRET=<long-random-string>. The server refuses to start in remote-allowed mode without the secret (SEC-051) so the credential is never embedded in HTML error pages.

Available Variables

# Request object - access all request data
req              # Full request object
req["params"]    # Route parameters (e.g., {id: "123"})
req["query"]     # Query string parameters
req["body"]      # Request body (POST/PUT data)
req["headers"]   # HTTP headers

# Session
session          # Session data

# Breakpoint context
breakpoint_env   # Local variables at breakpoint

REPL Features

# Use @ to reference the last result
req["params"]["id"]  # Returns: "123"
@ + 100              # Returns: 223 (uses last result)

# Navigation with arrow keys
# Up/Down: Browse command history

# Execute any valid soli code
print("Debug output")user["name"];        # Inspect variable
config["database"];  # View configuration

Quick Inspect Buttons

Click the quick inspect buttons to instantly view common data:

# Available quick inspect buttons:
req        # View full request object
params     # View route parameters
query      # View query string
body       # View request body
session    # View session data
headers    # View HTTP headers

AQL Query Log

When the server runs with --dev, every AQL query a request executes through the Model layer is captured into a per-request stack. The dev_queries() builtin returns that stack, so you can render a debug bar showing every query, its bind variables, and how long it took.

Return Shape

dev_queries() returns an Array<Hash> with these keys per entry:

Key Type Description
queryStringThe AQL sent to SoliDB
bind_varsHash | nullBind variables, or null if none
duration_msFloatWall-clock time in milliseconds

Controller Example

def index
  users = User.where("doc.active == true").all
  posts = Post.includes("author").all

  # dev_queries() returns [] in production, populated under --dev
  render("users/index", {
    "users":   users,
    "posts":   posts,
    "queries": dev_queries()
  })
end

Debug Bar Partial

<% if queries.length > 0 %>
  <div class="dev-bar">
    <h3><%= queries.length %> AQL queries</h3>
    <ol>
      <% for q in queries %>
        <li>
          <code><%= h(q["query"]) %></code>
          <% if q["bind_vars"] != null %>
            <small>binds: <%= h(json_stringify(q["bind_vars"])) %></small>
          <% end %>
          <span><%= q["duration_ms"] %> ms</span>
        </li>
      <% end %>
    </ol>
  </div>
<% end %>

Coverage

Logged
  • All Model operations (all, where, find, create, update, destroy, count)
  • Eager-loaded includes and soft-delete scopes
  • Validation lookups (uniqueness)
  • HABTM join-table operations
Not Logged (v1)
  • Direct Solidb(host, db).query(...) calls
  • Mocked queries from register_query_mock (short-circuit before the executor)
  • Internal session-storage queries via SoliDBClient

Zero Production Cost

In production, the executor never calls into the logger — the gate is a single relaxed atomic load. dev_queries() always returns [] there, so it's safe to leave the debug bar partial in your layout unconditionally.

Per-request Flamegraph

Click flame in the dev bar to expand a hierarchical flamegraph of every span captured during the request — middleware, before/after actions, controller dispatch, view, partials, every Soli function call, plus DB and HTTP. Hover any rectangle for an exact duration; click to zoom in; double-click to reset.

What gets captured

Spans are emitted at well-known framework wrap points and at every interpreter-level function call. Color encodes the kind:

Kind Color Source
middlewareamberEach scoped or global middleware invocation
before_action / after_actiondim amberPer controller hook fire
actioncyanTop-level handler dispatch (e.g. posts#show)
view / partialgreenEach render(...) / render_partial(...)
dbpurpleEach AQL query (name = first 80 chars)
httppinkEach outbound HTTP.* call
fnslateEvery Soli function call (interpreter path)

Export to Perfetto / chrome://tracing

The flame panel includes a ⬇ trace.json link that downloads the same data as Chrome Trace Event Format JSON. Drop the file into ui.perfetto.dev or open chrome://tracing for full timeline navigation, search, and aggregation across spans.

Production-safe. The flamegraph capture is gated on --dev: every span hook short-circuits on a thread-local enable flag, so production servers don't pay the cost. The panel is only injected into text/html responses, never into JSON or other content types.

HTMx-aware. Soli skips dev bar injection on responses to requests carrying the HX-Request: true header. HTMx swaps a fragment into a page that already shows the dev bar, so re-injecting the bar into the fragment would stack a duplicate at the bottom of the page on every swap.

Reading the chart

The X-axis is request time in microseconds (0 = request start). Y-axis is stack depth: parents are above their children. A wide rectangle near the top is a slow phase; a wide rectangle deep in the chart is a slow function. Look for:

  • A view rectangle that spans most of the request → render dominates; check the partial breakdown row in the render panel.
  • Many narrow purple rectangles at the same depth → likely an N+1; the AQL panel will flag it.
  • A very wide cyan action with thin children → time is spent in user code outside the framework hooks; turn on the fn spans (always on under --dev) and zoom in to find the hot function.

Page Requests

Every response the dev server sends carries an X-Soli-Route header naming the route that handled it (e.g. users#show). The dev bar header shows the current page's route right after the URL, and clicking the URL expands a Requests panel listing every route the page touched — the main page plus each XHR / fetch / HTMx call it fired afterwards.

How it works

The main page's row is rendered server-side. For everything after that, the dev bar patches fetch and XMLHttpRequest once, reads X-Soli-Route off each response, and appends a row (method, URL, route, status, duration). Each row's duration is the server-side app render time (from the X-Soli-Render-Us header) — the real time spent in your app, not the client round-trip (which folds in queue + network + transfer). Hover the duration to see the round-trip too. The count next to the URL updates as calls come in. HTMx uses XHR/fetch under the hood, so its swaps are captured too — and because a swap doesn't reload the page, the list keeps growing across swaps.

The dev bar isn't re-injected on an HTMx swap (that would stack a second bar), so the header follows HTMx navigations instead: when HTMx pushes the URL (hx-push-url / hx-boost), the header's route and render time update to that request. Background or widget HTMx requests that don't change the URL are added to the panel but leave the header alone.

Inspect any request

Click a row to retarget the db, http, kv, and flame panels (and their header badges) to that request — so you can open the flamegraph or query list for a specific XHR call, not just the main page. Every dev response also carries an X-Soli-Request-Id; the bar stores each request's snapshot server-side (a small ring buffer of the most recent requests) and the row-click fetches /__solidev/request/:id to re-render its panels in place. The selected row is highlighted; row 0 is always the page itself.

Replay a request

Each row carries a button. Click it to replay that request server-side: the dev server re-dispatches the captured request (same method, path, query, headers, and body) through the real worker path, so the handler runs again — handy for reproducing a bug without re-driving the UI. The bar then retargets its panels to the replay's fresh X-Soli-Request-Id, so you see the new run's queries, HTTP calls, and flamegraph immediately. Replayed responses are tagged X-Soli-Replay: 1.

Replaying re-runs side effects. A replayed POST / PATCH / DELETE re-executes its mutation (a second insert, a second charge, …), exactly as if the client had resubmitted. The per-form CSRF token check is skipped for replays (the session token may have rotated since capture), and multipart uploads aren't re-parsed in this version — the raw body is re-sent but file fields arrive empty. Replay only exists under --dev: the capture store is empty in production and the endpoint isn't mounted.

Production-safe. The route is recorded behind the same --dev thread-local gate as the other panels, and the X-Soli-Route header is only emitted in dev mode. In production no header is sent and no dev bar (or its script) is injected, so there is zero runtime cost.

Scope & caveats. Only same-origin requests that hit a Soli route (i.e. carry X-Soli-Route) become rows — static assets, cross-origin fetches, and 404s are ignored. The list resets on each full page load (it reflects the current page). Only the most recent requests are kept for inspection, so a long-idle row may report it has aged out. WebSocket, SSE, and sendBeacon traffic is not captured, and a request fired before the dev bar's script runs may be missed.

Database browser

With soli serve --dev, open /__soli/db to browse your SoliDB data straight from the app — no separate admin tool. It lists every collection (system collections hidden), and each links to a paginated row table at /__soli/db/<collection> (?page= / ?size=). Open one document at /__soli/db/<collection>/<_key> as pretty JSON.

The index also has a read-only query box: run an SDBQL FOR … RETURN and see the rows in a table. Mutating queries (INSERT/UPDATE/REPLACE/REMOVE/UPSERT) are rejected, collection names in row/document views are allow-listed against the real list, and LIMIT values are bound — but treat it as a dev convenience, not a hardened console.

Dev-only. The routes exist solely under --dev and read the same DB your app uses; they are never mounted in production.

Development Mode

Start the development server with hot reload to enable all debugging features:

# Start development server with hot reload
soli serve

# Allow the debug REPL from another trusted local machine
# (SEC-051: the secret is required — startup refuses without it)
SOLI_DEV_REPL_ALLOW_REMOTE=1 SOLI_DEV_REPL_SECRET=<long-random-string> soli serve . --dev

# The server will automatically reload when files change
# Breakpoints and debug page work in this mode

Development vs Production

Feature Development Production
BreakpointsEnabledDisabled
Debug PageFull interactiveSimple error
Hot ReloadEnabledDisabled
Stack TracesDetailedMinimal
AQL Query Log (dev_queries())Captured per requestAlways returns []

Security Warning

Never run in development mode in production. The debug page exposes sensitive information including request data, environment variables, and source code. Only set SOLI_DEV_REPL_ALLOW_REMOTE=1 on trusted local networks, and always pair it with SOLI_DEV_REPL_SECRET so the REPL credential isn't embedded in HTML error pages (SEC-051).

Next Steps

Combine debugging with testing to build robust applications.