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
42 changes: 38 additions & 4 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,22 +56,56 @@ jobs:
with:
go-version: '1.24'

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
cache-dependency-path: internal/dashboard/ui/package-lock.json

- name: Install dashboard dependencies
working-directory: internal/dashboard/ui
run: npm ci

- name: Type-check dashboard
working-directory: internal/dashboard/ui
run: npm run typecheck

- name: Build dashboard
working-directory: internal/dashboard/ui
run: npm run build

- name: Verify committed dashboard assets
run: git diff --exit-code -- internal/dashboard/static/dashboard.js internal/dashboard/static/styles.css

- name: Install SQLite driver (CGO)
run: sudo apt-get update && sudo apt-get install -y libsqlite3-dev

- name: Test core module
env:
GOTOOLCHAIN: local
run: go test -v ./...

- name: Test submodules
- name: Test Go 1.24 submodules
env:
GOTOOLCHAIN: local
PG_CONN: postgres://testuser:testpass@localhost:5432/testdb?sslmode=disable
REDIS_CONN: redis://localhost:6379/0
MONGO_URI: mongodb://root:root@localhost:27017
run: |
for m in store/postgres store/redis store/mongodb store/sqlite telemetry mcp; do
for m in store/postgres store/redis store/mongodb store/sqlite mcp; do
echo "== $m"
(cd "$m" && go test -v ./...)
done

- name: Build examples
run: cd cmd/examples && go build ./...
- name: Set up Go 1.25
uses: actions/setup-go@v5
with:
go-version: '1.25'

- name: Test Go 1.25 modules and build examples
env:
GOTOOLCHAIN: local
run: |
(cd telemetry && go test -v ./...)
(cd cmd/examples && go build ./...)
3 changes: 1 addition & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,11 @@ Thumbs.db

# Node.js dependencies
node_modules/
package-lock.json

# Built frontend files (these are committed for Go embed)
# internal/dashboard/static/dashboard.js
# internal/dashboard/static/styles.css

# Example binaries
cmd/examples/*/profiling-example
test-build
test-build
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,35 @@

All notable changes to GoVisual are recorded here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [v2.0.1] - 2026-09-03

This patch release hardens the capture-to-replay workflow and fixes several cases where observability could change application behavior or present misleading data.

### Security

- Dashboard and MCP endpoints now reject untrusted Host values and cross-origin browser requests, and dashboard SSE no longer opts into wildcard CORS.
- Dashboard replay loads a stored request by ID and pins the destination to `WithReplayBaseURL(...)` or a validated loopback dashboard origin. Remote dashboards require an explicit replay base URL.
- MCP replay and generated curl commands now require and use `WithBaseURL(...)`; captured Host values are never used as destinations. Existing MCP replay setups that relied on captured Host must configure `WithBaseURL(...)`.
- Replay strips Host, content length, hop-by-hop headers, connection-nominated headers, and stored redaction placeholders. Redirect following remains disabled.

### Fixed

- Capped request-body capture no longer replaces the application's body with the truncated copy. The handler receives the complete original stream, including read failures and close behavior.
- Response headers are captured at commit time and credential-bearing values are redacted before storage.
- `RequestLog.RawPath` preserves encoded path semantics during replay; PostgreSQL and SQLite persist both it and `Host` while migrating existing tables safely.
- SQL instrumentation preserves optional `database/sql/driver` interfaces used for context-aware connections, health checks, session reset, validation, named values, and column conversion.
- Store write failures are logged by default and can be routed through `WithErrorHandler(...)` without failing the application request.
- `ResponseWriter.Unwrap()` now works with `http.ResponseController` and compatible middleware.
- Dashboard Errors includes captured errors and panics, live updates no longer race the initial fetch, comparisons retain requested order, and duration units render correctly.
- Profiling now reports allocation and GC deltas for the request window while identifying process-wide heap and goroutine gauges accurately. Existing persisted v2.0.0 records retain their original cumulative GC values.
- MCP outputs have hard ceilings, replay reports omitted redacted headers, `diff_replay` accepts credential/body overrides and compares the full bounded replay body, and `save_as_test` accepts an optional `expected_status`.
- Generated curl commands quote custom methods, disable curl's `@file` body expansion, use only safe destinations, and reject incomplete or oversized captured bodies.

### Developer experience

- The dashboard dependency lockfile is committed, and the development build moves to a patched esbuild release. CI now installs the locked graph, type-checks and rebuilds the UI, verifies generated assets, and tests modules against their declared Go versions.
- Dashboard, configuration, API, request-logging, and contributor documentation now match the v2 runtime behavior.

## [v2.0.0] - 2026-07-02

The 2.0 release turns GoVisual into a runtime debugger a coding agent can drive, keeps the core module dependency-free, and hardens every prior rough edge with a full test suite.
Expand Down
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ claude mcp add govisual --transport http http://localhost:8080/mcp

**Tools:** `get_last_error`, `list_requests`, `get_request`, `search_requests`, `get_stats`, `get_debug_context`, `replay_request`, `diff_replay`, `await_request`, `save_as_test`, `copy_as_curl`, `clear_requests`.

Responses are token-aware: bounded list sizes, capped body excerpts, sizes reported so the agent knows when to ask for more. The endpoint is loopback-only by default. Replays are pinned to the base URL you configured, so an agent cannot use it to reach arbitrary hosts.
Responses are token-aware: bounded lists and diagnostics, capped body excerpts, and sizes reported so the agent knows when to ask for more. The endpoint is loopback-only by default. Replay, diff, and generated curl commands require and are pinned to `WithBaseURL(...)`.

Full guide: [docs/claude-code.md](docs/claude-code.md).

Expand Down Expand Up @@ -165,7 +165,7 @@ The event appears on the request's Logs tab and (with profiling on) inside the m
`http://localhost:8080/__viz` by default. Customize with `WithDashboardPath("/__debug")`.

- **Inbox, Errors, Slow**: filter captured requests by status and duration.
- **Request drawer** per request: Overview, Headers, Body, Trace (middleware, SQL, outbound HTTP), Logs, Performance (CPU, memory, GC, flame graph when profiling is on).
- **Request drawer** per request: Overview, Headers, Body, Trace (middleware, SQL, outbound HTTP), Logs, Performance (allocations, process heap, goroutines, GC, and a flame graph when profiling is on).
- **Analytics**: per-route request counts, p50/p95, error rates.
- **Agents**: recent MCP tool calls with their arguments, so you can watch a coding agent debug the app in real time.
- **Environment**: Go version, GOOS/GOARCH, memory stats, allowlisted env vars.
Expand Down Expand Up @@ -253,11 +253,13 @@ handler := govisual.Wrap(

// Storage (in-memory by default)
govisual.WithStore(myStore),
govisual.WithErrorHandler(func(err error) { logger.Error("capture failed", "error", err) }),

// Dashboard security
govisual.WithAllowRemote(), // loopback-only by default
govisual.WithBasicAuth("admin", "s3cret"),
govisual.WithReplayEnabled(true), // opt-in, SSRF-checked
govisual.WithReplayEnabled(true), // opt-in; destination is pinned
govisual.WithReplayBaseURL("http://127.0.0.1:8080"), // required with WithAllowRemote
govisual.WithSystemInfo("GOPATH", "HOME"), // env vars require an explicit allowlist

// Profiling (feeds the Performance tab and the bottleneck analyzer)
Expand All @@ -278,8 +280,8 @@ Full option reference: [docs/api-reference.md](docs/api-reference.md). Longer co

The dashboard sees every captured request and response, so v2 defaults are conservative:

- **Loopback-only** unless `WithAllowRemote()`. Pair remote access with auth.
- **Request replay** is off unless `WithReplayEnabled(true)`. Enabling it opens `POST /__viz/api/replay`, which makes the server issue an outbound request. Targets that resolve to private IPs are rejected before the call.
- **Loopback-only** unless `WithAllowRemote()`. Never expose a remote dashboard without `WithBasicAuth`, `WithDashboardAuth`, or equivalent authentication in an outer handler.
- **Request replay** is off unless `WithReplayEnabled(true)`. Replays load a captured request by ID and are pinned to `WithReplayBaseURL(...)`, or to a validated loopback dashboard origin when no base URL is configured. `WithAllowRemote()` requires an explicit replay base URL. Clients may edit the method, path, headers, and body, but cannot choose another destination host.
- **System info** is off unless `WithSystemInfo(...)`. Environment variables are only exposed if you list them by name.
- **Basic auth** via `WithBasicAuth(user, pass)`, or a custom check via `WithDashboardAuth(func(*http.Request) bool)`.
- **Sensitive headers** (Authorization, Cookie, Set-Cookie, X-Api-Key, X-Auth-Token, X-Csrf-Token) are redacted at capture time. The header name is kept, the value is replaced.
Expand Down
2 changes: 1 addition & 1 deletion cmd/examples/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ require (
github.com/doganarif/govisual/store/redis v0.0.0-00010101000000-000000000000
github.com/doganarif/govisual/store/sqlite v0.0.0-00010101000000-000000000000
github.com/doganarif/govisual/telemetry v0.0.0-00010101000000-000000000000
github.com/doganarif/govisual/v2 v2.0.0
github.com/doganarif/govisual/v2 v2.0.1
github.com/mattn/go-sqlite3 v1.14.47
go.opentelemetry.io/otel v1.44.0
go.opentelemetry.io/otel/trace v1.44.0
Expand Down
38 changes: 36 additions & 2 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,24 @@ govisual.WithStore(pg)

---

### `WithErrorHandler`

```go
func WithErrorHandler(fn func(error)) Option
```

Receives request-capture persistence errors synchronously after the wrapped handler returns. The callback should return promptly; its panics are recovered and logged so they cannot replace the application result. When omitted, GoVisual logs storage errors through the standard library logger.

**Example:**

```go
govisual.WithErrorHandler(func(err error) {
logger.Error("govisual capture failed", "error", err)
})
```

---

### `WithShutdownContext`

```go
Expand Down Expand Up @@ -254,7 +272,7 @@ govisual.WithDashboardAuth(func(r *http.Request) bool {
func WithReplayEnabled(enabled bool) Option
```

Enables the dashboard's `/api/replay` endpoint. Disabled by default because the endpoint lets the server make arbitrary outbound HTTP requests (an SSRF primitive). Only enable it behind authentication and/or loopback-only access.
Enables the dashboard's `/api/replay` endpoint. Disabled by default. Replay loads a captured request by ID and sends it only to the configured replay base URL or, for a loopback-only dashboard, the validated dashboard origin. `WithAllowRemote()` requires an explicit replay base URL. Only enable replay behind authentication and/or loopback-only access.

**Example:**

Expand All @@ -264,6 +282,22 @@ govisual.WithReplayEnabled(true)

---

### `WithReplayBaseURL`

```go
func WithReplayBaseURL(baseURL string) Option
```

Pins request replay to a server-configured HTTP or HTTPS origin. It is required with `WithAllowRemote()` and is also useful when the application is behind a reverse proxy or the browser-facing dashboard origin is not reachable from the application process. Dashboard clients cannot override this authority.

**Example:**

```go
govisual.WithReplayBaseURL("http://127.0.0.1:8080")
```

---

### `WithSystemInfo`

```go
Expand All @@ -288,7 +322,7 @@ govisual.WithSystemInfo("GOPATH", "GOOS")
func WithProfiling(enabled bool) Option
```

Enables per-request performance profiling. When enabled, each request captures CPU time, memory allocations, goroutine counts, SQL queries (via `WrapDriver`), and outbound HTTP calls (via `WrapTransport`).
Enables per-request performance profiling. Requests that meet `WithProfileThreshold` retain metrics for the selected profile types, including allocation and GC deltas for the profiling window, process heap and goroutine gauges, SQL queries (via `WrapDriver`), and outbound HTTP calls (via `WrapTransport`).

**Example:**

Expand Down
4 changes: 3 additions & 1 deletion docs/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ failing request, and verify fixes with `diff_replay` (expect

## Security notes

- The MCP endpoint answers loopback addresses only, unless `gvmcp.WithAllowRemote()` is set. Pair remote access with `gvmcp.WithToken("...")`.
- The MCP endpoint answers loopback addresses only, unless `gvmcp.WithAllowRemote()` is set. Never enable remote access without `gvmcp.WithToken("...")` or equivalent authentication in an outer handler.
- `replay_request` can change the method, path, headers, and body — but never the destination. Replays always target your app (`WithBaseURL`), so the endpoint is not an SSRF primitive.
- Replay, diff, and generated curl commands require `WithBaseURL`; captured Host values are never trusted as destinations.
- Redacted credentials are never replayed implicitly. Supply deliberate header overrides to `replay_request` or `diff_replay` when an authenticated comparison is required.
- Sensitive headers (Authorization, Cookie, API keys) are redacted at capture time, before anything reaches the store or the agent.
13 changes: 8 additions & 5 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,30 +22,32 @@ handler := govisual.Wrap(
| `WithDashboardPath(string)` | URL path for the dashboard | `/__viz` | `govisual.WithDashboardPath("/__debug")` |
| `WithRequestBodyLogging(bool)` | Capture request bodies | false | `govisual.WithRequestBodyLogging(true)` |
| `WithResponseBodyLogging(bool)` | Capture response bodies | false | `govisual.WithResponseBodyLogging(true)` |
| `WithIgnorePaths(...string)` | Path patterns to exclude from capture | `[]` | `govisual.WithIgnorePaths("/health", "/metrics")` |
| `WithIgnorePaths(...string)` | Path patterns to exclude from capture | `["/favicon.ico"]` | `govisual.WithIgnorePaths("/health", "/metrics")` |
| `WithMaxBodyBytes(int)` | Cap on captured body size in bytes. `0` = 1 MiB default, positive = explicit cap, negative = unbounded. | 1 MiB | `govisual.WithMaxBodyBytes(64 << 10)` |
| `WithSampleRate(float64)` | Fraction of requests to capture (0..1). Uncaptured requests pass through untouched. | 1.0 | `govisual.WithSampleRate(0.1)` |
| `WithStore(store.Store)` | Storage backend for captured requests. Omit to use an in-memory store bounded by `WithMaxRequests`. | in-memory | `govisual.WithStore(pg)` |
| `WithErrorHandler(func(error))` | Route capture persistence failures synchronously; callbacks should return promptly and panics are recovered. | standard logger | `govisual.WithErrorHandler(reportError)` |
| `WithShutdownContext(ctx)` | Cancel this context to release storage resources on shutdown. | none | `govisual.WithShutdownContext(ctx)` |

### Dashboard Security

The dashboard is loopback-only by default. `WithAllowRemote` opts out of that restriction; pair it with an auth option when you do.
The dashboard is loopback-only by default. `WithAllowRemote` opts out of that restriction; never use it without `WithBasicAuth`, `WithDashboardAuth`, or equivalent authentication in an outer handler.

| Option | Description | Default | Example |
| --- | --- | --- | --- |
| `WithLocalhostOnly()` | Restrict the dashboard to loopback addresses. This is the default; the option exists to make intent explicit. | on | `govisual.WithLocalhostOnly()` |
| `WithAllowRemote()` | Allow non-loopback addresses to reach the dashboard. Pair with `WithBasicAuth` or `WithDashboardAuth`. | off | `govisual.WithAllowRemote()` |
| `WithAllowRemote()` | Allow non-loopback addresses to reach the dashboard. Must be paired with `WithBasicAuth`, `WithDashboardAuth`, or equivalent outer authentication. | off | `govisual.WithAllowRemote()` |
| `WithBasicAuth(user, pass)` | Protect the dashboard with HTTP Basic Auth (constant-time compare) | off | `govisual.WithBasicAuth("admin", "secret")` |
| `WithDashboardAuth(fn)` | Custom auth function run on every dashboard request; return true to allow | off | `govisual.WithDashboardAuth(myCheck)` |
| `WithReplayEnabled(bool)` | Enable the request replay endpoint (SSRF primitive — keep it gated) | false | `govisual.WithReplayEnabled(true)` |
| `WithReplayEnabled(bool)` | Enable request replay. Replays load a captured request by ID and use a server-pinned destination. | false | `govisual.WithReplayEnabled(true)` |
| `WithReplayBaseURL(string)` | Pin replay to an application origin. Required with `WithAllowRemote()`; a loopback-only dashboard can use its validated origin. | validated loopback dashboard origin | `govisual.WithReplayBaseURL("http://127.0.0.1:8080")` |
| `WithSystemInfo(...string)` | Enable the system-info endpoint; env vars shown only if allowlisted | false | `govisual.WithSystemInfo("GOPATH")` |

### Profiling Options

| Option | Description | Default | Example |
| --- | --- | --- | --- |
| `WithProfiling(bool)` | Enable per-request CPU/memory/goroutine profiling | false | `govisual.WithProfiling(true)` |
| `WithProfiling(bool)` | Enable per-request allocation, GC, goroutine, SQL, and outbound HTTP profiling | false | `govisual.WithProfiling(true)` |
| `WithProfileType(ProfileType)` | Which profiles to collect: `ProfileCPU`, `ProfileMemory`, `ProfileGoroutine`, `ProfileAll` | `ProfileAll` | `govisual.WithProfileType(govisual.ProfileCPU)` |
| `WithProfileThreshold(duration)` | Only keep profiles for requests slower than this | 10ms | `govisual.WithProfileThreshold(50 * time.Millisecond)` |
| `WithMaxProfileMetrics(int)` | Maximum number of profile records to retain | 1000 | `govisual.WithMaxProfileMetrics(500)` |
Expand Down Expand Up @@ -81,6 +83,7 @@ handler := govisual.Wrap(
mux,
govisual.WithBasicAuth("admin", "secret"),
govisual.WithReplayEnabled(true),
govisual.WithReplayBaseURL("http://127.0.0.1:8080"),
govisual.WithSystemInfo("GOPATH", "GOOS"),
)
```
Expand Down
Loading
Loading