How to Create a Thread-Safe Custom Sink in spdlog

To create a thread-safe custom sink in spdlog, inherit from spdlog::sinks::base_sink<std::mutex> and override the protected virtual methods sink_it_() and flush_(); the base class handles all locking and formatter management automatically.

The gabime/spdlog library isolates log output logic through a sink hierarchy centered on the base_sink template. When you need to send log messages to a custom destination—whether a network socket, database, or proprietary API—deriving from this template allows you to create a thread-safe custom sink in spdlog without implementing mutex logic yourself.

Understanding the base_sink Template

The foundation of every thread-safe sink is the base_sink<Mutex> class defined in include/spdlog/sinks/base_sink.h. This template implements the public spdlog::sinks::sink interface while handling the thread-safety glue internally.

The class holds two critical members:

  • std::unique_ptr<formatter> formatter_ – The pattern formatter shared with the logger
  • Mutex mutex_ – The synchronization primitive (template parameter)

When a log message arrives, the public log() method acquires mutex_ and calls your implemented sink_it_(). Similarly, flush() locks before calling flush_(). This design ensures that your implementation code runs under exclusive lock without explicit synchronization code.

Choosing the Mutex Type

The Mutex template parameter determines the thread-safety guarantee:

  • std::mutex – Provides full thread-safe operation; use this for *_mt (multi-threaded) sink aliases
  • spdlog::details::null_mutex – A zero-cost dummy mutex defined in include/spdlog/details/null_mutex.h; use this for *_st (single-threaded) sinks where you need no locking overhead

By instantiating your sink with std::mutex, you automatically satisfy spdlog's thread-safety requirements for concurrent logging.

Step-by-Step Implementation

Follow these steps to implement your own thread-safe sink:

  1. Include the required headers – You need spdlog/sinks/base_sink.h and optionally spdlog/details/null_mutex.h for the single-threaded variant

  2. Derive from base_sink<std::mutex> – This establishes the thread-safe behavior

  3. Override sink_it_() – Format the details::log_msg using formatter_->format() and write to your destination

  4. Override flush_() – Implement any destination-specific flush semantics

  5. Export type aliases – Provide my_sink_mt and my_sink_st typedefs for convenience

Complete Working Example

Below is a complete implementation that writes to a file while maintaining an in-memory ring buffer of recent messages:

// my_custom_sink.h
#pragma once

#include <spdlog/sinks/base_sink.h>
#include <spdlog/details/null_mutex.h>
#include <spdlog/fmt/fmt.h>
#include <fstream>
#include <mutex>
#include <vector>

namespace spdlog {
namespace sinks {

template <class Mutex>
class my_custom_sink : public base_sink<Mutex>
{
public:
    explicit my_custom_sink(const std::string& filename, std::size_t cache = 100)
        : file_(filename, std::ios::app), cache_size_(cache) {}

protected:
    void sink_it_(const details::log_msg& msg) override
    {
        // Format message using the base class formatter
        memory_buf_t formatted;
        base_sink<Mutex>::formatter_->format(msg, formatted);

        // Write to file (executing under lock from base_sink)
        file_.write(formatted.data(), formatted.size());
        
        // Maintain ring buffer of recent lines
        const std::size_t eol_len = std::strlen(details::os::default_eol);
        if (lines_.size() < cache_size_)
        {
            lines_.emplace_back(formatted.begin(),
                                formatted.end() - static_cast<std::ptrdiff_t>(eol_len));
        }
        else
        {
            lines_.erase(lines_.begin());
            lines_.emplace_back(formatted.begin(),
                                formatted.end() - static_cast<std::ptrdiff_t>(eol_len));
        }
    }

    void flush_() override
    {
        file_.flush();
    }

private:
    std::ofstream file_;
    std::size_t cache_size_;
    std::vector<std::string> lines_;
};

// Type aliases for convenient instantiation
using my_custom_sink_mt = my_custom_sink<std::mutex>;
using my_custom_sink_st = my_custom_sink<details::null_mutex>;

} // namespace sinks
} // namespace spdlog

Usage:

#include "my_custom_sink.h"
#include <spdlog/spdlog.h>

int main()
{
    auto sink = std::make_shared<spdlog::sinks::my_custom_sink_mt>("output.log", 50);
    auto logger = std::make_shared<spdlog::logger>("custom_logger", sink);
    spdlog::register_logger(logger);
    
    logger->info("Thread-safe logging to custom destination");
}

Key Source Files in gabime/spdlog

For reference implementations and detailed interfaces, examine these files in the repository:

Summary

  • Derive from base_sink<std::mutex> to create thread-safe sinks; the base class automatically locks before calling your sink_it_() implementation
  • Override sink_it_() and flush_() to implement destination-specific output logic; these methods run under the protection of the mutex
  • Use formatter_->format(msg, buf) to apply the configured pattern to the raw log message
  • Provide _mt and _st aliases using std::mutex and null_mutex respectively to follow spdlog naming conventions
  • Reference tests/test_sink.h for a minimal working example of the pattern

Frequently Asked Questions

What is the difference between _mt and _st suffixes in spdlog sinks?

The _mt suffix indicates a thread-safe sink using std::mutex, while _st indicates a single-threaded sink using spdlog::details::null_mutex. The null_mutex provides zero-overhead locking for scenarios where the logger is only accessed from a single thread, eliminating synchronization costs entirely.

Do I need to manually lock inside sink_it_() when using base_sink?

No. The base_sink template acquires the mutex in its public log() method before calling your protected sink_it_() implementation. Your code runs under exclusive lock automatically, so you should not acquire additional locks unless interfacing with external thread-unsafe resources that require their own synchronization.

Can I use a custom mutex type instead of std::mutex?

Yes. As long as the type satisfies the BasicLockable requirements (provides lock() and unlock() methods), you can pass it as the template argument to base_sink. This allows integration with custom synchronization primitives, recursive mutexes, or instrumented locks for debugging.

How do I access the formatted message string inside sink_it_()?

Call base_sink<Mutex>::formatter_->format(msg, buf) where buf is a memory_buf_t (typically fmt::memory_buffer). This populates the buffer with the formatted text according to the pattern set via set_pattern() or set_formatter(). Access the string content using buf.data() and buf.size() before writing to your destination.

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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →