How the 99 Logging System Works: Configure Debug Output in Neovim
The 99 plugin uses a structured JSON logger with configurable sinks (print, file, or void) and per-request context that you enable by passing a logger table to require("99").setup() with level = DEBUG and type = "print" or type = "file".
ThePrimeagen's 99 repository includes a lightweight yet powerful logging system designed for debugging AI-assisted coding workflows in Neovim. Unlike standard print debugging, this logging system captures structured, JSON-formatted events with per-request scoping, configurable output destinations, and automatic lifecycle tracking. Whether you need to trace a single request or audit all provider interactions, understanding how to configure debug output is essential for plugin development and troubleshooting.
Understanding the 99 Logging System Architecture
The architecture centers on a singleton module_logger instantiated in lua/99/logger/logger.lua. This design ensures consistent configuration across the plugin while allowing scoped child loggers for individual requests.
Core Logger Implementation
The Logger class in lua/99/logger/logger.lua handles instantiation, sink management, and log emission. When you call Logger:new(level), it creates a logger instance defaulting to a VoidSink (silent output) and the specified numeric level.
local Logger = require("99.logger.logger")
local logger = Logger:new(level) -- defaults to FATAL level if unspecified
The constructor initializes the internal cache and sink at lines 17-25 of lua/99/logger/logger.lua, establishing the foundation for all subsequent logging operations.
Log Levels and Filtering
Log levels are defined as numeric constants in lua/99/logger/level.lua, where lower values indicate higher verbosity:
| Level | Value | Usage |
|---|---|---|
DEBUG |
-5 | Detailed tracing |
INFO |
0 | General information |
WARN |
5 | Warnings |
ERROR |
10 | Error conditions |
FATAL |
15 | Critical failures |
The logger filters messages in the _log method (lines 78-82 of lua/99/logger/logger.lua) by comparing the logger's current level against the message's level. Only messages with a level greater than or equal to the logger's threshold are emitted.
Output Sinks: Print, File, and Void
The logging system supports three distinct output destinations implemented as sink classes within lua/99/logger/logger.lua:
PrintSink: Outputs JSON strings to Neovim's command line usingprint()FileSink: Appends JSON lines to a specified file pathVoidSink: Discards all log output (default)
When you configure debug output, you select the sink type via the type parameter, with "file" requiring an additional path parameter.
How to Configure Debug Output in 99
Configuration occurs through the plugin's setup function in lua/99/init.lua, which passes your logger options to Logger:configure at lines 63-64.
Basic Setup Configuration
To enable debug output, invoke setup with a logger table specifying the level and sink type:
require("99").setup({
logger = {
level = require("99.logger.level").DEBUG, -- or -5
type = "print", -- "print", "file", or "void"
},
})
For file-based logging, add the path parameter:
require("99").setup({
logger = {
level = vim.log.levels.DEBUG,
type = "file",
path = vim.fn.stdpath("cache") .. "/99.log",
},
})
Logger Configuration Options
The Logger:configure method (lines 89-115 in lua/99/logger/logger.lua) accepts the following options:
level: Numeric log threshold (default:FATAL)type: Sink type string ("print","file","void")path: Absolute file path (required whentype = "file")print_on_error: Boolean flag that forces ERROR/FATAL logs toPrintSinkeven when the primary sink isVoidSinkorFileSinkmax_requests_in_logger_cache: Integer controlling how many request-specific log histories to retain in memory (default varies by implementation)
These settings update the module-wide module_logger instance used throughout the plugin.
Per-Request Logger Context
Each request receives its own scoped logger instance via set_id and set_area methods (lines 60-73 in lua/99/logger/logger.lua). This ensures logs are tagged with request-specific metadata:
-- In request-context.lua or similar
local ctx_logger = logger:set_id(request_id) -- adds `id` field to all logs
ctx_logger = ctx_logger:set_area("Provider") -- adds `Area` field
ctx_logger:debug("operation_name", "key", value)
These methods return new logger instances (clones) rather than mutating the original, allowing safe reuse of the base logger across multiple concurrent requests.
Advanced Logging Features
Beyond basic configuration, the 99 logging system provides structured output and inspection capabilities essential for debugging complex AI interactions.
Structured JSON Output Format
Every log line emitted by the _log method (lines 75-99 in lua/99/logger/logger.lua) is encoded as a JSON object containing:
level: String representation of the level (e.g.,"DEBUG")msg: The log message identifiertimestamp: Optional time information- Custom fields: Any key-value pairs passed to the logging method (e.g.,
"results","data") - Context fields:
idandAreaif set viaset_id/set_area
For example, a debug call in the provider module:
logger:debug("stdout", "data", data)
Produces JSON similar to:
{"level":"DEBUG","msg":"stdout","data":"...subprocess output...","id":"req-123","Area":"Provider"}
Log Caching and Request Inspection
The logger maintains an in-memory cache of recent log entries per request ID. When set_id is called, the logger initializes a cache bucket for that request. The _log method stores each JSON line in this bucket (lines 95-97 in lua/99/logger/logger.lua).
This cache enables:
- Test assertions: Verifying that specific log lines were generated during request processing
- UI inspection: Displaying request history in interfaces without re-emitting to sinks
- Debugging: Retrieving full request logs even when the primary sink is
VoidSink
Control the cache size via max_requests_in_logger_cache in the configuration to balance memory usage against inspection depth.
Summary
The 99 logging system provides structured, configurable debug output through these key mechanisms:
- Singleton architecture: A module-wide
module_loggerinlua/99/logger/logger.luamanages all logging operations - Level-based filtering: Numeric levels from
DEBUG(-5) toFATAL(15) defined inlua/99/logger/level.luacontrol verbosity - Configurable sinks: Choose between
PrintSink,FileSink, orVoidSinkvia thetypeparameter insetup() - Per-request scoping:
set_id()andset_area()methods create contextual loggers that tag entries with request metadata - JSON structured output: All logs emit as JSON objects containing level, message, context fields, and custom data
- In-memory caching: Recent logs are retained per request ID for inspection and testing purposes
Configure debug output by calling require("99").setup() with a logger table specifying your desired level and sink type.
Frequently Asked Questions
How do I enable debug logging in 99?
Pass a logger configuration table to the setup function with level set to DEBUG and type set to "print" or "file". For example: require("99").setup({ logger = { level = require("99.logger.level").DEBUG, type = "print" } }). This updates the module-wide logger to emit debug-level messages to your chosen sink.
What is the difference between PrintSink, FileSink, and VoidSink?
PrintSink outputs JSON log lines to Neovim's command line using the print() function, making it ideal for interactive debugging. FileSink appends log lines to a specified file path, useful for persistent logging across sessions. VoidSink discards all output and serves as the default silent mode when no logging is configured.
How does per-request logging work in 99?
Each request receives a scoped logger instance created via set_id(request_id) and optionally set_area("ComponentName"). These methods clone the base logger and attach metadata fields (id and Area) to every subsequent log entry. This ensures logs are tagged with their originating request, enabling filtering and caching of logs per request lifecycle.
Where are log entries cached and how can I access them?
Log entries are cached in-memory within the logger instance, specifically in per-request buckets created when set_id is called. The _log method stores each JSON line in the cache (up to max_requests_in_logger_cache requests). While the public API for retrieval isn't detailed in the source, the cache is designed for internal testing and UI inspection components to access recent request history without re-emitting to sinks.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →