Devtools
Application debugging and performance monitoring. Combines a request inspector (individual entry capture) with an APM dashboard (pre-aggregated metrics) — think Laravel Telescope + Laravel Pulse in one package.Two modes:
- Inspector — captures individual requests, queries, exceptions, logs, and jobs. Every entry is stored in full and grouped into batches so you can trace a single request across all its queries, logs, and side effects.
- Metrics — records pre-aggregated performance data (slow requests, slow queries) in time buckets for at-a-glance monitoring without scanning raw entries.
/_devtools.
Installation
bun add @strav/devtools
bun strav install devtools
The install command copies files into your project:
config/devtools.ts— toggle collectors, recorders, thresholds.database/schemas/devtools_entries.ts— the entries table schema.database/schemas/devtools_aggregates.ts— the aggregates table schema.
Setup
Register the provider
import { DevtoolsProvider } from '@strav/devtools'
app.use(new DevtoolsProvider())
That's it. The provider automatically:
- Registers
DevtoolsManageras a singleton - Creates the storage tables
- Adds the request-tracking middleware to the router
- Mounts the dashboard at
/_devtools - Tears down collectors on shutdown
database provider.
Options:
| Option | Default | Description |
|---|---|---|
ensureTables |
true |
Auto-create the entries and aggregates tables |
middleware |
true |
Auto-register the request-tracking middleware on the router |
dashboard |
true |
Auto-register the dashboard routes at /_devtools |
guard |
— | Custom auth guard for the dashboard (see Dashboard auth) |
To add a custom dashboard auth guard:
app.use(new DevtoolsProvider({
guard: (ctx) => ctx.get('user')?.isAdmin === true,
}))
To disable auto-registration of middleware or dashboard (for manual control):
app.use(new DevtoolsProvider({ middleware: false, dashboard: false }))
Manual setup
If you prefer full control over middleware and route placement:
import DevtoolsManager from '@strav/devtools'
import { devtools } from '@strav/devtools'
import { registerDashboard } from '@strav/devtools/dashboard/routes'
// Register and resolve the manager
app.singleton(DevtoolsManager)
app.resolve(DevtoolsManager)
// Create tables
await devtools.ensureTables()
// Or via CLI: bun strav devtools:setup
// Add request-tracking middleware
router.use(devtools.middleware())
// Mount the dashboard
registerDashboard(router)
The middleware captures request/response data, sets a batch ID on the context, and triggers query collection via a SQL proxy. All other collectors (exceptions, logs, jobs) activate automatically through Emitter events.
Configure
Editconfig/devtools.ts:
import { env } from '@strav/kernel'
export default {
enabled: env('DEVTOOLS_ENABLED', 'true').bool(),
routes: {
aliases: {
dashboard: 'devtools.dashboard', // Dashboard routes
api: 'devtools.api' // API routes
},
subdomain: undefined // Optional subdomain
},
storage: {
pruneAfter: 24, // hours
},
collectors: {
request: { enabled: true, sizeLimit: 64 },
query: { enabled: true, slow: 100 },
exception: { enabled: true },
log: { enabled: true, level: 'debug' },
job: { enabled: true },
},
recorders: {
slowRequests: { enabled: true, threshold: 1000, sampleRate: 1.0 },
slowQueries: { enabled: true, threshold: 1000, sampleRate: 1.0 },
},
}
Set DEVTOOLS_ENABLED=false in production to disable all collection with zero overhead.
Configuration reference
| Key | Default | Description |
|---|---|---|
enabled |
true |
Whether to enable all devtools collection |
routes.aliases.dashboard |
'devtools.dashboard' |
Route alias prefix for dashboard routes |
routes.aliases.api |
'devtools.api' |
Route alias prefix for API routes |
routes.subdomain |
undefined |
Optional subdomain for devtools routes |
storage.pruneAfter |
24 |
Hours after which to delete old entries |
collectors.* |
varies | Collector-specific configuration |
recorders.* |
varies | Recorder-specific configuration |
Collectors
Collectors capture individual events and store them as entries.
Request
Middleware-based. Captures method, path, status, duration, memory usage, request/response headers, and IP. Sensitive headers (authorization, cookie) are automatically redacted.
Tags: status:<code>, slow (if >1000ms), user:<id> (if authenticated).
collectors: {
request: { enabled: true, sizeLimit: 64 },
}
The sizeLimit option (in KB) controls the maximum body size captured.
Query
Intercepts SQL queries by proxying theBun.sql connection. Captures query text, bindings, duration, and a familyHash for grouping similar queries. Queries to devtools' own tables (_strav_devtools_*) are excluded.
Tags: slow (if exceeding threshold).
collectors: {
query: { enabled: true, slow: 100 },
}
The slow option (in ms) marks queries above this duration.
Exception
Listens tohttp:error Emitter events. Captures error class, message, stack trace (first 20 lines), and request context (path, method). Uses familyHash to group occurrences of the same error.
Tags: error class name (e.g. TypeError).
Log
Listens tolog:entry Emitter events. Filters by minimum level.
collectors: {
log: { enabled: true, level: 'debug' },
}
Supported levels (lowest to highest): trace, debug, info, warn, error, fatal. Setting level: 'info' captures info, warn, error, and fatal — not trace or debug.
Tags: level name. Entries at error or fatal also get the error tag.
Job
Listens toqueue:dispatched, queue:processed, and queue:failed Emitter events. Captures job name, queue, status, duration, and error message (for failures).
Tags: job name, status (dispatched, processed, failed).
Recorders
Recorders aggregate metrics into time buckets for the dashboard. They don't store individual entries — they update counters and extreme values.
Slow Requests
Records requests exceeding the threshold. Aggregates:count, max.
recorders: {
slowRequests: { enabled: true, threshold: 1000, sampleRate: 1.0 },
}
Slow Queries
Records queries exceeding the threshold. Normalizes SQL text (replaces string literals and$N parameters) for consistent grouping. Aggregates: count, max.
recorders: {
slowQueries: { enabled: true, threshold: 1000, sampleRate: 1.0 },
}
sampleRate
Both recorders support asampleRate option (0.0 to 1.0). At 1.0, every event is recorded. At 0.5, roughly half are sampled. Useful for high-traffic applications.
Batch correlation
Every request gets a uniquebatchId. All entries produced during that request — the request itself, its queries, log entries, and exceptions — share the same batchId. The dashboard detail view shows all related entries.
Downstream code can read the batch ID:
const batchId = ctx.get('_devtools_batch_id')
devtools helper
Thedevtools helper provides the primary convenience API:
import { devtools } from '@strav/devtools'
Querying entries
// List recent entries (all types)
const entries = await devtools.entries()
// Filter by type
const requests = await devtools.entries('request', 50)
const queries = await devtools.entries('query', 100, 0)
// Find by UUID
const entry = await devtools.find('550e8400-e29b-41d4-a716-446655440000')
// Find all entries in a batch
const batch = await devtools.batch(entry.batchId)
// Search by tag
const slow = await devtools.byTag('slow')
const user42 = await devtools.byTag('user:42')
// Count
const total = await devtools.count()
const exceptions = await devtools.count('exception')
Querying metrics
import { PERIODS } from '@strav/devtools'
// Time-series data
const hourly = await devtools.aggregates('slow_request', PERIODS.ONE_HOUR, 'count')
const daily = await devtools.aggregates('slow_query', PERIODS.ONE_DAY, 'max')
// Top offenders
const topSlow = await devtools.topKeys('slow_request', PERIODS.ONE_HOUR, 'count', 10)
Pruning
// Prune entries older than the configured pruneAfter value
const { entries, aggregates } = await devtools.prune()
// Or specify hours explicitly
const result = await devtools.prune(48)
Dashboard
The built-in SPA dashboard is served at/_devtools and provides seven views:
Inspector views:
| View | Shows |
|---|---|
| Requests | Method, path, status, duration, time |
| Queries | SQL preview, duration, slow flag |
| Exceptions | Error class, message, request path |
| Logs | Level, message, timestamp |
| Jobs | Name, status, queue, duration |
| View | Shows |
|---|---|
| Slow Requests | Top endpoints by count and max duration |
| Slow Queries | Top queries by count and max duration |
Clicking any entry opens a detail view with full content and all related batch entries. The dashboard auto-refreshes every 5 seconds.
Dashboard auth
By default, the dashboard is only accessible indevelopment and local environments. In production, it returns 403.
To allow access in production, pass a custom guard:
import { registerDashboard } from '@strav/devtools/dashboard/routes'
registerDashboard(router, (ctx) => {
const user = ctx.get('user')
return user?.isAdmin === true
})
The guard receives the request context and returns true or false (or a Promise). It's called on every request to the /_devtools prefix.
You can also use the middleware directly:
import { dashboardAuth } from '@strav/devtools/dashboard/middleware'
router.group({
prefix: '/_devtools',
middleware: [dashboardAuth((ctx) => ctx.get('user')?.isAdmin)]
}, (r) => {
// custom routes
})
CLI commands
devtools:setup
Create the storage tables (idempotent):
bun strav devtools:setup
devtools:prune
Delete old entries and aggregates:
bun strav devtools:prune
bun strav devtools:prune --hours 48
Options:
--hours— Delete data older than this many hours (default:24).
Dashboard routes and API
The dashboard includes both frontend routes and a REST API, organized with configurable route aliases for easy programmatic access.
Dashboard routes (devtools.dashboard alias)
| Path | Route Name | Description |
|---|---|---|
/_devtools |
devtools.dashboard.home |
Main dashboard SPA |
API routes (devtools.api alias)
The dashboard mounts a REST API under /_devtools/api/:
| Method | Path | Route Name | Description |
|---|---|---|---|
| GET | /api/entries |
devtools.api.entries |
List entries. Query: type, limit, offset |
| GET | /api/entries/:uuid |
devtools.api.entry |
Single entry by UUID |
| GET | /api/entries/:uuid/batch |
devtools.api.entry_batch |
All entries in the same batch |
| GET | /api/entries/tag/:tag |
devtools.api.entries_by_tag |
Entries by tag. Query: limit |
| GET | /api/metrics/:type |
devtools.api.metrics |
Time-series aggregates. Query: period, aggregate, limit |
| GET | /api/metrics/:type/top |
devtools.api.metrics_top |
Top keys by value. Query: period, aggregate, limit |
| GET | /api/stats |
devtools.api.stats |
Entry counts by type |
| DELETE | /api/entries |
devtools.api.prune_entries |
Prune old data. Query: hours |
Using named routes
With route aliases configured, you can access the devtools API programmatically:
import { route, routeUrl } from '@strav/http'
// Get recent entries
const entries = await route('devtools.api.entries', {
params: { type: 'request', limit: 50 }
})
// Get a specific entry
const entry = await route('devtools.api.entry', {
params: { uuid: '550e8400-e29b-41d4-a716-446655440000' }
})
// Get all entries in a batch
const batchEntries = await route('devtools.api.entry_batch', {
params: { uuid: entry.uuid }
})
// Get entries by tag
const slowEntries = await route('devtools.api.entries_by_tag', {
params: { tag: 'slow' }
})
// Get metrics data
const metrics = await route('devtools.api.metrics', {
params: { type: 'slow_request' },
query: { period: '3600', aggregate: 'count', limit: '24' }
})
// Get top performers
const topQueries = await route('devtools.api.metrics_top', {
params: { type: 'slow_query' },
query: { period: '3600', aggregate: 'count', limit: '10' }
})
// Get stats
const stats = await route('devtools.api.stats')
// Prune old data
await route('devtools.api.prune_entries', {
method: 'DELETE',
query: { hours: '48' }
})
// Generate dashboard URL
const dashboardUrl = routeUrl('devtools.dashboard.home')
Custom route aliases
You can customize the route aliases in your devtools configuration:
// config/devtools.ts
export default {
routes: {
aliases: {
dashboard: 'debug.dashboard', // Routes: debug.dashboard.home
api: 'debug.api' // Routes: debug.api.entries, etc.
}
}
}
// Or mount on a subdomain
export default {
routes: {
subdomain: 'devtools' // Accessible at devtools.example.com
}
}
Entry types
type EntryType =
| 'request'
| 'query'
| 'exception'
| 'log'
| 'job'
| 'cache' // reserved for future use
| 'mail' // reserved for future use
| 'event' // reserved for future use
| 'schedule' // reserved for future use
Aggregate functions
type AggregateFunction = 'count' | 'min' | 'max' | 'sum' | 'avg'
Aggregate periods
import { PERIODS } from '@strav/devtools'
PERIODS.ONE_HOUR // 3600
PERIODS.SIX_HOURS // 21600
PERIODS.ONE_DAY // 86400
PERIODS.SEVEN_DAYS // 604800
Database tables
_strav_devtools_entries
| Column | Type | Description |
|---|---|---|
id |
bigserial |
Primary key |
uuid |
uuid |
Unique entry identifier |
batch_id |
uuid |
Groups entries from the same request |
type |
varchar(30) |
Entry type (request, query, etc.) |
family_hash |
varchar(64) |
Groups similar entries (same error, same query) |
content |
jsonb |
Full entry payload |
tags |
text[] |
Searchable string tags |
created_at |
timestamptz |
When the entry was recorded |
batch_id, (type, created_at DESC), family_hash (partial, where not null).
_strav_devtools_aggregates
| Column | Type | Description |
|---|---|---|
id |
bigserial |
Primary key |
bucket |
int |
Unix timestamp of the time bucket start |
period |
int |
Bucket size in seconds |
type |
varchar(30) |
Metric type (slow_request, slow_query) |
key |
text |
Grouping key (e.g. GET /api/users) |
aggregate |
varchar(10) |
Function (count, min, max, sum, avg) |
value |
numeric(20,2) |
Aggregated value |
count |
int |
Number of samples in this bucket |
(bucket, period, type, aggregate, key).
Zero-cost when disabled
All core event emissions use alistenerCount() guard:
if (Emitter.listenerCount('log:entry') === 0) return
When devtools is not installed or enabled: false, there are no listeners registered, so the guard short-circuits before allocating any event objects. The SQL proxy is also skipped entirely when disabled.
Error handling
import { DevtoolsError } from '@strav/devtools'
DevtoolsError extends ConfigurationError from core. It's thrown when DevtoolsManager is accessed before being resolved through the container.
Testing
Disable devtools in tests to avoid recording test traffic:
.env.test
DEVTOOLS_ENABLED=false
Or don't resolve DevtoolsManager in your test bootstrap — collectors and recorders simply won't activate, and the listenerCount() guards ensure zero overhead.
Full setup example
import { app } from '@strav/kernel'
import { DevtoolsProvider } from '@strav/devtools'
app.use(new DevtoolsProvider({
guard: (ctx) => {
if (process.env.NODE_ENV === 'production') {
const user = ctx.get('user')
return user?.isAdmin === true
}
return true
},
}))
await app.start()