# How the 99 Logging System Works: Configure Debug Output in Neovim

> Learn how the 99 logging system works. Configure debug output in Neovim using structured JSON logging with customizable sinks and per-request context. Enable debug logs easily.

- Repository: [ThePrimeagen/99](https://github.com/theprimeagen/99)
- Tags: internals
- Published: 2026-02-16

---

**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`](https://github.com/ThePrimeagen/99/blob/main/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`](https://github.com/ThePrimeagen/99/blob/main/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.

```lua
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`](https://github.com/ThePrimeagen/99/blob/main/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`](https://github.com/ThePrimeagen/99/blob/main/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`](https://github.com/ThePrimeagen/99/blob/main/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`](https://github.com/ThePrimeagen/99/blob/main/lua/99/logger/logger.lua):

- **`PrintSink`**: Outputs JSON strings to Neovim's command line using `print()`
- **`FileSink`**: Appends JSON lines to a specified file path
- **`VoidSink`**: 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`](https://github.com/ThePrimeagen/99/blob/main/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:

```lua
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:

```lua
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`](https://github.com/ThePrimeagen/99/blob/main/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 when `type = "file"`)
- **`print_on_error`**: Boolean flag that forces ERROR/FATAL logs to `PrintSink` even when the primary sink is `VoidSink` or `FileSink`
- **`max_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`](https://github.com/ThePrimeagen/99/blob/main/lua/99/logger/logger.lua)). This ensures logs are tagged with request-specific metadata:

```lua
-- 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`](https://github.com/ThePrimeagen/99/blob/main/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 identifier
- **`timestamp`**: Optional time information
- **Custom fields**: Any key-value pairs passed to the logging method (e.g., `"results"`, `"data"`)
- **Context fields**: `id` and `Area` if set via `set_id`/`set_area`

For example, a debug call in the provider module:

```lua
logger:debug("stdout", "data", data)

```

Produces JSON similar to:

```json
{"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`](https://github.com/ThePrimeagen/99/blob/main/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_logger` in [`lua/99/logger/logger.lua`](https://github.com/ThePrimeagen/99/blob/main/lua/99/logger/logger.lua) manages all logging operations
- **Level-based filtering**: Numeric levels from `DEBUG` (-5) to `FATAL` (15) defined in [`lua/99/logger/level.lua`](https://github.com/ThePrimeagen/99/blob/main/lua/99/logger/level.lua) control verbosity
- **Configurable sinks**: Choose between `PrintSink`, `FileSink`, or `VoidSink` via the `type` parameter in `setup()`
- **Per-request scoping**: `set_id()` and `set_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.