Skip to content
63 changes: 47 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# shinyloadtest

A load-generation tool for [Shiny](https://shiny.posit.co/) applications.
shinyloadtest replays recorded sessions against a deployed Shiny app, simulating
concurrent users to measure application performance under load.
A load-testing tool for [Shiny](https://shiny.posit.co/) applications.
shinyloadtest records a user session and replays it with concurrent workers
to measure application performance under load.

## Installation

Expand All @@ -20,13 +20,49 @@ Or run directly with npx:
npx @posit-dev/shinyloadtest --help
```

## Usage
## Quick Start

```bash
shinyloadtest replay recording.log https://example.com/app [options]
# 1. Record a session against a running Shiny app
shinyloadtest record https://example.com/app

# 2. Interact with the app in the browser at the printed proxy URL,
# then close the browser tab to stop recording.

# 3. Replay the recording with multiple concurrent users
shinyloadtest replay recording.log https://example.com/app --workers 5
```

## Recording

```bash
shinyloadtest record <app-url> [options]
```

### Options
Starts a local reverse proxy that sits between your browser and the Shiny
application. All HTTP and WebSocket traffic is captured to a recording file.
Navigate to the proxy URL printed on startup, interact with the app as a
typical user would, then close the browser tab (or press Ctrl+C) to stop.

### Record Options

| Option | Description |
|--------|-------------|
| `--port <n>` | Local proxy port (default: `8600`) |
| `--host <host>` | Local proxy host (default: `127.0.0.1`) |
| `--output <file>` | Output recording file (default: `recording.log`) |
| `--open` | Open browser automatically |

## Replay

```bash
shinyloadtest replay <recording> [app-url] [options]
```

Replays a recorded session with one or more concurrent workers. If `app-url`
is omitted, the target URL from the recording file is used.

### Replay Options

| Option | Description |
|--------|-------------|
Expand All @@ -49,24 +85,19 @@ shinyloadtest supports authentication via environment variables:
| `SHINYLOADTEST_PASS` | Password for Shiny Server Pro or Posit Connect |
| `SHINYLOADTEST_CONNECT_API_KEY` | API key for Posit Connect |

These variables are used during both recording and replay. If the app requires
login and environment variables are not set, `record` will prompt interactively
(TTY required).

> **Note:** If the recording was made with a Connect API key, playback must
> also use a Connect API key. Likewise, if the recording was made without an
> API key, playback must not use one.

## Example

```bash
shinyloadtest replay recording.log https://rsc.example.com/app \
--workers 5 \
--loaded-duration-minutes 10 \
--output-dir load-test-results
```

## Companion Package

shinyloadtest is designed to work with the
[shinyloadtest](https://rstudio.github.io/shinyloadtest) R package.
Use the R package to record sessions and analyze load test results.
Use the R package to analyze load test results.

## Migration from shinycannon

Expand Down
6 changes: 6 additions & 0 deletions examples/loadtest-demo-py/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ async def _update_products():
@reactive.event(input.run)
async def result():
product = input.product()
if not product:
return None
n = simulation_sizes[input.sim_size()]

# Fake slow database read for historical sales data
Expand All @@ -114,6 +116,8 @@ async def result():
@render_plotly
async def sim_plot():
res = await result()
if res is None:
return None

fig = go.Figure()
fig.add_trace(
Expand Down Expand Up @@ -147,6 +151,8 @@ async def sim_plot():
@render.text
async def result_text():
res = await result()
if res is None:
return ""
q05, q50, q95 = np.quantile(res["demand"], [0.05, 0.5, 0.95])
return (
f"Product: {res['product']} ({res['category']})\n"
Expand Down
3 changes: 2 additions & 1 deletion examples/loadtest-demo-r/app.R
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ ui <- page_sidebar(
),
input_task_button("run", "Run Forecast")
),
class = "bslib-page-dasboard",
class = "bslib-page-dashboard",
shiny::useBusyIndicators(),
card(
min_height = 300,
Expand Down Expand Up @@ -84,6 +84,7 @@ server <- function(input, output, session) {

# Step 3 -> Step 4: run forecast on button click
result <- eventReactive(input$run, {
req(input$product)
product <- input$product
n <- simulation_sizes[[input$sim_size]]

Expand Down
102 changes: 88 additions & 14 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ import * as fs from "node:fs"
import { Command } from "commander"
import { bold, cyan, dim, green, magenta, yellow } from "yoctocolors"
import { VERSION } from "./version.js"
import { defaultOutputDir } from "./output.js"
import { defaultOutputDir } from "./replay/output.js"
import { parseLogLevel, LogLevel } from "./logger.js"
import { getCreds } from "./auth.js"
import { type Creds } from "./types.js"
import { readRecording } from "./recording.js"
import { type RecordOptions } from "./record/record.js"

// ---------------------------------------------------------------------------
// ParsedArgs
Expand Down Expand Up @@ -85,11 +86,19 @@ export function serializeArgs(args: ParsedArgs): {
return { argsString, argsJson }
}

// ---------------------------------------------------------------------------
// CLI result discriminated union
// ---------------------------------------------------------------------------

export type CliResult =
| { command: "replay"; args: ParsedArgs }
| { command: "record"; options: RecordOptions }

// ---------------------------------------------------------------------------
// Argument parsing
// ---------------------------------------------------------------------------

export function parseArgs(argv?: string[]): ParsedArgs {
export function parseArgs(argv?: string[]): CliResult {
const program = new Command()

const colorArgument = (str: string): string => {
Expand All @@ -107,7 +116,65 @@ export function parseArgs(argv?: string[]): ParsedArgs {
.description("Load testing tool for Shiny applications.")
.version(VERSION)

let result: ParsedArgs | undefined
let result: CliResult | undefined

const recordCmd = program
.command("record")
.configureHelp({
styleTitle: (str) => bold(str),
styleArgumentTerm: (str) => colorArgument(str),
styleArgumentText: (str) => colorArgument(str),
styleOptionTerm: (str) => cyan(str),
})
.description(
"Record a Shiny application session for later replay.\n\n" +
"Starts a local reverse proxy. Navigate your browser through the proxy\n" +
"to interact with the Shiny application; all WebSocket and HTTP traffic\n" +
"is captured to a recording file.\n\n" +
dim("Example:") +
"\n" +
` ${cyan("$")} shinyloadtest record https://rsc.example.com/app`,
)
.argument("<app-url>", "URL of the Shiny application to record")
.option("--port <n>", "Local proxy port", "8600")
.option("--host <host>", "Local proxy host", "127.0.0.1")
.option("--output <file>", "Output recording file", "recording.log")
.option("--open", "Open browser automatically", false)
.addHelpText(
"after",
`\n${bold("Environment variables:")}\n` +
` ${yellow("SHINYLOADTEST_USER")} Username for SSP or Connect auth\n` +
` ${yellow("SHINYLOADTEST_PASS")} Password for SSP or Connect auth\n` +
` ${yellow("SHINYLOADTEST_CONNECT_API_KEY")} Posit Connect API key\n` +
`\n${dim(" Legacy SHINYCANNON_* environment variables are also supported.")}`,
)
.action(
(
targetUrl: string,
opts: {
port: string
host: string
output: string
open: boolean
},
) => {
const port = Number(opts.port)
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(`Invalid port value: ${opts.port}`)
}

result = {
command: "record",
options: {
targetUrl,
port,
host: opts.host,
output: opts.output,
open: opts.open,
},
}
},
)

const replayCmd = program
.command("replay")
Expand Down Expand Up @@ -231,17 +298,20 @@ export function parseArgs(argv?: string[]): ParsedArgs {
}

result = {
recordingPath,
appUrl,
workers,
loadedDurationMinutes,
startInterval,
headers,
outputDir: opts.outputDir,
overwriteOutput: opts.overwriteOutput,
debugLog: opts.debugLog,
logLevel: parseLogLevel(opts.logLevel),
creds: getCreds(),
command: "replay",
args: {
recordingPath,
appUrl,
workers,
loadedDurationMinutes,
startInterval,
headers,
outputDir: opts.outputDir,
overwriteOutput: opts.overwriteOutput,
debugLog: opts.debugLog,
logLevel: parseLogLevel(opts.logLevel),
creds: getCreds(),
},
}
},
)
Expand All @@ -259,6 +329,10 @@ export function parseArgs(argv?: string[]): ParsedArgs {
replayCmd.help()
}

if (userArgs.length === 1 && userArgs[0] === "record") {
recordCmd.help()
}

program.parse(raw)

if (!result) {
Expand Down
18 changes: 13 additions & 5 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,25 @@ import * as path from "node:path"
import { CookieJar } from "tough-cookie"
import { VERSION } from "./version.js"
import { parseArgs, serializeArgs } from "./cli.js"
import { record } from "./record/record.js"
import { readRecording, recordingDuration } from "./recording.js"
import { createLogger } from "./logger.js"
import { createOutputDir } from "./output.js"
import { runEnduranceTest } from "./worker.js"
import { createOutputDir } from "./replay/output.js"
import { runEnduranceTest } from "./replay/worker.js"
import { SERVER_TYPE_NAMES, ServerType } from "./types.js"
import { HttpClient } from "./http.js"
import { detectServerType } from "./detect.js"
import { TerminalUI } from "./ui.js"
import { ReplayTerminalUI } from "./replay/ui.js"

async function main(): Promise<void> {
const args = parseArgs()
const result = parseArgs()

if (result.command === "record") {
await record(result.options)
return
}

const args = result.args

const recording = readRecording(args.recordingPath)
const duration = recordingDuration(recording)
Expand Down Expand Up @@ -91,7 +99,7 @@ async function main(): Promise<void> {
const { argsString, argsJson } = serializeArgs(args)

const ui = process.stderr.isTTY
? new TerminalUI({
? new ReplayTerminalUI({
version: VERSION,
appUrl: args.appUrl,
workers: args.workers,
Expand Down
Loading
Loading