Skip to content

Debugging with Logs

Scenarist provides a flexible logging infrastructure for debugging scenario matching, state management, and request handling. Logging is disabled by default (silent) and can be enabled when needed.

import { createScenarist, createConsoleLogger } from '@scenarist/express-adapter';
// Or: import { createScenarist, createConsoleLogger } from '@scenarist/nextjs-adapter/app';
const scenarist = createScenarist({
enabled: true,
scenarios,
// Enable logging with pretty format at info level
logger: createConsoleLogger({ level: 'info' }),
});

Scenarist includes two logger implementations:

NoOpLogger

Default. Silent - no output. Zero overhead for production-like tests.

ConsoleLogger

Human-readable or JSON output. Great for debugging scenario matching issues.

The ConsoleLogger provides human-readable or JSON output to the console.

import { createConsoleLogger } from '@scenarist/express-adapter';
// Pretty format (default) - colored, human-readable
const logger = createConsoleLogger({ level: 'info' });
// JSON format - for log aggregation tools
const logger = createConsoleLogger({ level: 'info', format: 'json' });
// Debug level with category filtering
const logger = createConsoleLogger({
level: 'debug',
categories: ['matching', 'scenario'],
});
type ConsoleLoggerConfig = {
/** Minimum log level to output */
level: 'error' | 'warn' | 'info' | 'debug' | 'trace';
/** Output format (default: 'pretty') */
format?: 'pretty' | 'json';
/** Filter to specific categories (default: all categories) */
categories?: LogCategory[];
};

Levels are hierarchical - each level includes all less verbose levels:

LevelDescriptionUse Case
errorCritical failuresScenario not found, invalid config
warnPotential issuesNo mock matched, sequence exhausted
infoKey eventsScenario switched, mock selected
debugDecision logicMatch criteria evaluation, specificity scores
traceVerbose detailsRequest/response bodies, template replacement

Filter logs to specific areas of concern:

CategoryDescriptionExample Messages
lifecycleStartup, shutdownscenarist_started, scenarist_stopped
scenarioScenario switchingscenario_switched, scenario_not_found
matchingMock selectionmock_selected, mock_no_match, mock_candidates_found
sequenceSequence statesequence_advanced, sequence_exhausted
stateState capturestate_captured, state_injected
templateTemplate replacementtemplate_replaced
requestRequest lifecyclerequest_received, response_sent
// Only show matching and scenario logs
const logger = createConsoleLogger({
level: 'debug',
categories: ['matching', 'scenario'],
});
12:34:56.789 INF 🎬 [test-user-login] scenario | scenario_switched scenarioId=premium-user
12:34:56.801 INF 🎯 [test-user-login] matching | mock_selected mockIndex=2 specificity=5
12:34:56.802 DBG 🎯 [test-user-login] matching | mock_match_evaluated mockIndex=0 matched=false
12:34:56.803 DBG 🎯 [test-user-login] matching | mock_match_evaluated mockIndex=1 matched=true
12:34:56.810 INF 💾 [test-user-login] state | state_captured key=userId value=user-123
12:34:56.815 WRN 🎯 [test-user-login] matching | mock_no_match url=/api/unknown candidateCount=0

Features:

  • Timestamps with milliseconds
  • Level indicators: ERR, WRN, INF, DBG, TRC
  • Category icons for visual scanning
  • Colored test IDs (persistent per test)
  • Key-value data fields

When running tests in parallel, each test gets a unique test ID. The pretty format assigns persistent colors to each test ID, making it easy to trace logs from concurrent tests:

12:34:56.789 INF 🎯 [test-checkout-flow] matching | mock_selected
12:34:56.790 INF 🎯 [test-payment-process] matching | mock_selected
12:34:56.791 INF 🎯 [test-user-login] matching | mock_selected
12:34:56.792 INF 💾 [test-checkout-flow] state | state_captured

Each test ID gets a unique color that remains consistent throughout the test run.

The default logger - does nothing. Use for clean test output:

import { noOpLogger, createNoOpLogger } from '@scenarist/express-adapter';
// Singleton instance (recommended)
const logger = noOpLogger;
// Or create new instance (functionally identical)
const logger = createNoOpLogger();

When to use:

  • Production-like test runs where you want clean output
  • CI/CD pipelines where logs would be noise
  • Any situation where you don’t need Scenarist debugging

For easy toggling without code changes:

import {
createScenarist,
createConsoleLogger,
noOpLogger,
type LogLevel,
type LogFormat,
} from '@scenarist/express-adapter';
// Type-safe environment variable parsing
const LOG_LEVELS: ReadonlyArray<Exclude<LogLevel, 'silent'>> = [
'error', 'warn', 'info', 'debug', 'trace',
];
const LOG_FORMATS: ReadonlyArray<LogFormat> = ['pretty', 'json'];
const parseLogLevel = (value: string | undefined): Exclude<LogLevel, 'silent'> =>
LOG_LEVELS.includes(value as Exclude<LogLevel, 'silent'>)
? (value as Exclude<LogLevel, 'silent'>)
: 'info';
const parseLogFormat = (value: string | undefined): LogFormat =>
LOG_FORMATS.includes(value as LogFormat) ? (value as LogFormat) : 'pretty';
const scenarist = createScenarist({
enabled: process.env.NODE_ENV === 'test',
scenarios,
// Enable logging via SCENARIST_LOG=1
logger: process.env.SCENARIST_LOG
? createConsoleLogger({
level: parseLogLevel(process.env.SCENARIST_LOG_LEVEL),
format: parseLogFormat(process.env.SCENARIST_LOG_FORMAT),
})
: noOpLogger,
});

Then run tests with logging:

Terminal window
# Enable info-level logging
SCENARIST_LOG=1 pnpm test
# Enable debug-level logging
SCENARIST_LOG=1 SCENARIST_LOG_LEVEL=debug pnpm test
# JSON format for CI
SCENARIST_LOG=1 SCENARIST_LOG_FORMAT=json pnpm test

Add disableConsoleIntercept: true to your vitest.config.ts:

vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
// REQUIRED: Show Scenarist logging output for all tests
// Without this, console output is only shown for failed tests
disableConsoleIntercept: true,
// ... your other config
},
});

Then run tests with logging enabled:

Terminal window
SCENARIST_LOG=1 pnpm test

You’ll see output like:

09:00:12.269 DBG [unknown] 🎬 scenario scenario_registered scenarioId="default" mockCount=4
09:00:12.270 DBG [unknown] 🎬 scenario scenario_registered scenarioId="success" mockCount=3
09:00:12.308 INF [my-test-id] 🎬 scenario scenario_switched

Enable debug logging for the matching category:

const logger = createConsoleLogger({
level: 'debug',
categories: ['matching'],
});

You’ll see:

  • mock_candidates_found - How many mocks could potentially match
  • mock_match_evaluated - Each mock’s evaluation result and specificity score
  • mock_selected - Which mock was chosen and why
  • mock_no_match - When no mock matched (with the URL that failed)

Enable info logging for the scenario category:

const logger = createConsoleLogger({
level: 'info',
categories: ['scenario'],
});

You’ll see:

  • scenario_switched - When a test switches scenarios
  • scenario_not_found - When a requested scenario doesn’t exist

Enable debug logging for the state category:

const logger = createConsoleLogger({
level: 'debug',
categories: ['state'],
});

You’ll see:

  • state_captured - When state is extracted from a response
  • state_injected - When state is injected into a mock

Implement the Logger interface to integrate with your logging library:

import type { Logger, LogCategory, LogContext } from '@scenarist/core';
import pino from 'pino';
const pinoInstance = pino({ level: 'debug' });
const pinoLogger: Logger = {
error: (category, message, context, data) =>
pinoInstance.error({ category, ...context, ...data }, message),
warn: (category, message, context, data) =>
pinoInstance.warn({ category, ...context, ...data }, message),
info: (category, message, context, data) =>
pinoInstance.info({ category, ...context, ...data }, message),
debug: (category, message, context, data) =>
pinoInstance.debug({ category, ...context, ...data }, message),
trace: (category, message, context, data) =>
pinoInstance.trace({ category, ...context, ...data }, message),
isEnabled: (level) => pinoInstance.isLevelEnabled(level),
};
const scenarist = createScenarist({
enabled: true,
scenarios,
logger: pinoLogger,
});

Create a console logger with the specified configuration.

function createConsoleLogger(config: ConsoleLoggerConfig): Logger;
type ConsoleLoggerConfig = {
level: Exclude<LogLevel, 'silent'>;
format?: 'pretty' | 'json';
categories?: ReadonlyArray<LogCategory>;
};

Singleton logger that does nothing.

const noOpLogger: Logger;

Factory function to create a no-op logger.

function createNoOpLogger(): Logger;

The port interface all loggers implement:

interface Logger {
error(category: LogCategory, message: string, context: LogContext, data?: Record<string, unknown>): void;
warn(category: LogCategory, message: string, context: LogContext, data?: Record<string, unknown>): void;
info(category: LogCategory, message: string, context: LogContext, data?: Record<string, unknown>): void;
debug(category: LogCategory, message: string, context: LogContext, data?: Record<string, unknown>): void;
trace(category: LogCategory, message: string, context: LogContext, data?: Record<string, unknown>): void;
isEnabled(level: Exclude<LogLevel, 'silent'>): boolean;
}
type LogLevel = 'silent' | 'error' | 'warn' | 'info' | 'debug' | 'trace';
type LogCategory =
| 'lifecycle'
| 'scenario'
| 'matching'
| 'sequence'
| 'state'
| 'template'
| 'request';
type LogContext = {
readonly testId?: string;
readonly scenarioId?: string;
readonly requestUrl?: string;
readonly requestMethod?: string;
};

Scenarist’s logging is designed for minimal overhead:

When logging is disabled (the default), noOpLogger is used. Its methods are empty functions that V8’s JIT compiler can inline and potentially eliminate entirely:

// NoOpLogger implementation - effectively no-op
export const noOpLogger: Logger = {
error: () => {},
warn: () => {},
info: () => {},
debug: () => {},
trace: () => {},
isEnabled: () => false,
};

For high-frequency code paths, use isEnabled() to skip expensive string formatting when logging is disabled:

// Check before formatting expensive context
if (logger.isEnabled('debug')) {
logger.debug('matching', 'Details', context, {
body: JSON.stringify(largeObject), // Only formatted if needed
headers: Object.fromEntries(request.headers),
});
}

This pattern ensures that expensive operations like:

  • JSON serialization
  • Object transformation
  • String concatenation

…are only performed when the output will actually be used.

Simple function calls like logger.debug(category, message, context, data) are candidates for V8’s inlining optimization when hot. The straightforward signature and return type (void) help V8 make efficient inlining decisions.