Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@

## Unreleased

### Added
- **Journeys: your users' real paths, saved in your repo.** A journey file
(`prescale/journeys/<name>.yaml`) lists the requests a user's path makes (log in, add to
cart, place an order), carrying tokens and IDs from one step to the next.
`prescale journeys` lists them; `prescale journeys verify <url>` replays each once, as one
user, and names the step that no longer answers as expected (the journey is stale, or the
app is broken). It exits non-zero, so it works as a CI check.
- Secrets come from environment variables (`{{env.NAME}}`); a literal password, token or
API key in a journey file is refused.
- Journeys that change data only run against local hosts or hosts you allow with
`--allow-writes`; journeys that send messages or delete data are never replayed.
- The example shop has a JSON API and two example journeys in `examples/journeys/`.

### Fixed
- **`prescale mcp` works on a fresh install again.** `pip install 'prescale[mcp]'` picked up the new
mcp 2.x, which renamed the server class PreScale uses, so the MCP server wouldn't start and told
Expand Down
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ From source: `git clone https://github.com/pyjeebz/PreScale.git && pip install .
| [`run <url>`](#run--what-breaks-first) | Ramp traffic; report what breaks first and at what load |
| [`investigate <url>`](#investigate--and-why) | …and *why* it breaks, plus how to fix it (`--app` to start your app first: [replica mode](#replica-mode--test-your-app-not-your-host)) |
| [`audit <url>`](#audit--hygiene-no-load) | Load-free HTTP hygiene check (compression, caching, CDN, HTTP/2) |
| [`journeys`](#journeys--your-users-real-paths) | List your journey files; `journeys verify <url>` checks they still replay |
| [`compare`](#ci--gate-on-capacity) | Capacity diff of two runs; regression gating for CI |
| [`profiles`](#frame-it-as-a-launch) | Launch scenarios for `--profile` |
| [`history` / `show`](#saved-runs-and-the-json-contract) | List and re-open saved runs |
Expand Down Expand Up @@ -166,6 +167,44 @@ Try this
→ Add a pooler (e.g. pgbouncer) and check the pool checkout timeout.
```

### `journeys` — your users' real paths

A journey is a path a user takes through your app (log in, add to cart, place an order), saved as the requests it makes. Journeys live in your repo as `prescale/journeys/<name>.yaml`:

```yaml
version: 1
name: checkout
type: checkout
steps:
- name: log in
request:
method: POST
path: /api/login
json: {email: "{{unique.email}}", password: "{{env.SHOP_PASSWORD}}"}
expect: {status: 200}
extract: {token: body.token}
- name: place order
request:
method: POST
path: /api/orders
headers: {Authorization: "Bearer {{token}}"}
expect: {status: 201}
```

```bash
prescale journeys # list them
prescale journeys verify http://localhost:3000 # replay each once, as one user
```

`verify` stops at the first step that doesn't answer as expected and calls the journey **stale**: the app changed, or it's broken. It exits non-zero, so it works as a CI check.

- `{{name}}` is a value an earlier step pulled out with `extract` (`body.<field>` or `header.<name>`); `{{unique.email}}` and `{{unique.id}}` are fresh each run; `{{env.NAME}}` reads an environment variable.
- **Secrets never go in the file.** A literal password, token, API key or `Authorization` value is refused; use `{{env.NAME}}`.
- Paths start with `/` and are relative to the URL you give (`http://host/app` + `/api/cart` is `http://host/app/api/cart`). A journey only calls that app: a request or redirect to another host fails the step.
- Journeys that change data (POST, PUT, PATCH, DELETE) run against local hosts, or a host you name with `--allow-writes staging.myapp.com`. Types `side_effect` (emails, SMS, invites) and `destructive` are never replayed.

Try it on the example shop: `python examples/fragile-shop.py 8400`, then `SHOP_PASSWORD=demo prescale journeys verify http://localhost:8400 --dir examples/journeys`.

### `audit` — hygiene, no load

A fast, load-free check of the HTTP-level footguns that decide how you scale — compression, static-asset caching, CDN, HTTP version, cookies on assets. Cheap enough to run on every commit.
Expand Down
165 changes: 165 additions & 0 deletions cli/src/prescale_cli/commands/journeys.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
"""`prescale journeys` - list the app's journeys, and check they still replay.

Journeys live in the app's repo as `prescale/journeys/*.yaml`. `verify` replays
each one once, as a single user: a step that fails there means the journey is
stale (the app changed) or the app is broken, never that it broke under load.
"""

from __future__ import annotations

import asyncio
import os
from pathlib import Path
from urllib.parse import urlparse

import click
from rich.console import Console
from rich.markup import escape
from rich.table import Table

from prescale_cli.commands.upload import header_option
from prescale_cli.journeys.model import DEFAULT_DIR, Journey, load_dir
from prescale_cli.journeys.replay import JourneyCheck, check

console = Console()

_LOCAL_HOSTS = {"localhost", "127.0.0.1", "0.0.0.0", "::1"}

dir_option = click.option(
"--dir", "directory", type=click.Path(file_okay=False, path_type=Path),
default=DEFAULT_DIR, show_default=True, help="Where the journey files are.")


@click.group(invoke_without_command=True)
@dir_option
@click.pass_context
def journeys(ctx: click.Context, directory: Path) -> None:
"""List the app's journeys (prescale/journeys/*.yaml).

\b
Check they still replay against your app:
prescale journeys verify http://localhost:3000
"""
if ctx.invoked_subcommand is not None:
return
found = _load_or_exit(directory)
table = Table(title=f"Journeys in {directory}")
for col in ("Name", "Type", "Weight", "Steps", "Changes data", "File"):
table.add_column(col)
for j in found:
table.add_row(j.name, j.type, f"{j.weight:g}", str(len(j.steps)),
"yes" if j.writes else "no", escape(str(j.source)))
console.print(table)


@journeys.command()
@click.argument("url")
@dir_option
@click.option("--journey", "only", multiple=True,
help="Check only this journey (repeatable).")
@click.option("--allow-writes", "allow_writes", multiple=True, metavar="HOST",
help="Let journeys that change data (POST, PUT, PATCH, DELETE) run against "
"this non-local host, e.g. staging.myapp.com (repeatable).")
@click.option("--timeout", default=10.0, type=float, help="Per-request timeout in seconds.")
@header_option
def verify(url: str, directory: Path, only: tuple[str, ...], allow_writes: tuple[str, ...],
timeout: float, headers: dict[str, str]) -> None:
"""Replay each journey once, as one user, and report any step that fails.

Exits non-zero if a journey is stale or the app couldn't be reached.

\b
prescale journeys verify http://localhost:3000
prescale journeys verify https://staging.myapp.com --allow-writes staging.myapp.com
"""
parsed = urlparse(url)
if parsed.scheme not in ("http", "https") or not parsed.netloc:
console.print(f"[red]Error:[/red] '{url}' is not a URL "
"(expected e.g. http://localhost:3000).")
raise SystemExit(2)
found = _load_or_exit(directory)
if only:
missing = sorted(set(only) - {j.name for j in found})
if missing:
console.print(f"[red]Error:[/red] no journey named {', '.join(missing)} "
f"in {directory}.")
raise SystemExit(2)
found = [j for j in found if j.name in only]

host = (parsed.hostname or "").lower()
_refuse_unallowed_writes(found, host, {h.lower() for h in allow_writes})
unset = sorted({name for j in found for name in j.env_names()} - set(os.environ))
if unset:
console.print(f"[red]Error:[/red] the journeys read environment variable(s) that "
f"aren't set: {', '.join(unset)}.")
raise SystemExit(2)

results = asyncio.run(_check_all(found, url, timeout=timeout, headers=headers))
for result in results:
_print_check(result)

broken = [r for r in results if not r.ok]
if broken:
stale = [r.journey.name for r in broken if r.stale]
if stale:
console.print(f"\n[yellow]{len(stale)} stale:[/yellow] {', '.join(stale)}. The app "
"no longer answers the way these journeys expect. If the change was "
"on purpose, update the journey files; if not, it's a bug.")
unreached = [r.journey.name for r in broken if not r.stale]
if unreached:
console.print(f"\n[red]{len(unreached)} couldn't run:[/red] {', '.join(unreached)}.")
raise SystemExit(1)
console.print(f"\n[green]✓ All {len(results)} journeys replay.[/green]")


def _load_or_exit(directory: Path) -> list[Journey]:
found, errors = load_dir(directory)
for error in errors:
console.print(f"[red]Error:[/red] {escape(error)}", highlight=False)
if errors:
raise SystemExit(2)
if not found:
console.print(f"No journeys in {escape(str(directory))}. Add one as "
f"{escape(str(directory))}/<name>.yaml.", highlight=False)
raise SystemExit(1)
return found


def _refuse_unallowed_writes(found: list[Journey], host: str, allowed: set[str]) -> None:
"""Journeys that change data run on local hosts, or on hosts named with
--allow-writes. Anywhere else they could change real data."""
if host in _LOCAL_HOSTS or host in allowed:
return
writers = [j.name for j in found if j.writes]
if writers:
console.print(f"[red]Error:[/red] {', '.join(writers)} change data (POST, PUT, PATCH "
f"or DELETE). Run them against {host} only if it's a staging or test "
f"environment, with --allow-writes {host}.")
raise SystemExit(2)


async def _check_all(found: list[Journey], base: str, *, timeout: float,
headers: dict[str, str]) -> list[JourneyCheck]:
return [await check(j, base, timeout=timeout, headers=headers) for j in found]


def _print_check(result: JourneyCheck) -> None:
journey = result.journey
mark = "[green]✓[/green]" if result.ok else ("[yellow]stale[/yellow]" if result.stale
else "[red]✗[/red]")
console.print(f"\n{mark} [bold]{escape(journey.name)}[/bold] [dim]({journey.type}, "
f"{len(journey.steps)} steps)[/dim]")
for i, step in enumerate(journey.steps, 1):
done = result.steps[i - 1] if i <= len(result.steps) else None
name, label = escape(step.name), escape(step.label)
if done is None:
console.print(f" [dim]{i}. {name} {label} (not run)[/dim]", highlight=False)
elif done.ok:
console.print(f" {i}. {name} [dim]{label}[/dim] "
f"{done.status} {done.latency * 1000:.0f}ms", highlight=False)
else:
console.print(f" {i}. {name} [dim]{label}[/dim] "
f"[red]{escape(done.problem or '')}[/red]", highlight=False)
if done.status is not None and done.status >= 500:
console.print(" [dim]A 5xx with no load is usually a bug in the app, "
"not a stale journey.[/dim]")
6 changes: 6 additions & 0 deletions cli/src/prescale_cli/journeys/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Journeys: a user's path through the app (log in, search, add to cart, order),
saved as the HTTP requests it makes, so PreScale can replay it.

A journey lives in the app's own repo as `prescale/journeys/<name>.yaml`.
`model` loads and checks those files; `replay` runs a journey's steps.
"""
Loading
Loading