Thread-Local Logging Context in C++: Using Thread-Local Context in logme

A useful log should do more than tell you what happened. It should also make it clear which request, connection, client, or background job a message belongs to.

When an application is still small, this usually seems easy enough. You can just include the extra data directly in the log call:

LogmeI(
  "Request %s: loading profile for user %d"
  , requestId
  , userId
);

The problem appears once the code gets deeper. The requestId is known at the request handler, but soon the same value is needed in a service, a repository, an HTTP client, and maybe in shared library code as well.

In many of those places, the request ID is not part of the function’s real job at all. It is being passed around only so it can eventually show up in a log entry.

That is how function signatures start filling up with arguments that exist purely for diagnostics:

void HandleRequest(
  const char* requestId
  , int userId
);

void LoadProfile(
  const char* requestId
  , int userId
);

void ReadFromDatabase(
  const char* requestId
  , int userId
);

As logging becomes more structured, the situation gets worse. It may no longer be just request_id. You may also want connection_id, tenant, a logging channel, a subsystem, or a temporary output override.

This is where thread-local logging context becomes useful. In logme, you can set the relevant logging context once at the boundary of an operation.

Then deeper code can continue using ordinary LogmeI(), LogmeW(), and similar calls. It does not need extra arguments purely for logging.

What can be attached to the current thread

logme does not expose one large LoggingContext object. Instead, it provides several separate mechanisms that work together.

You can set a default channel for the current thread:

LogmeThreadChannel(channel);

A subsystem:

LogmeThreadSubsystem(subsystem);

A set of structured fields:

LogmeThreadFields(fields);

And a temporary override:

LogmeThreadOverride(override);

There is also LogmeThreadName(...), which gives the current thread a readable name. It serves a similar purpose. However, internally it is handled differently from the thread_local values used for fields, channel, subsystem, and override.

The practical idea is the same in every case: the context is defined where it is known, and then used where logging actually happens.

Set request context once, then log normally

Imagine a server that has just received a request. At the entry point, you already know the logging channel, the subsystem, and the request ID.

Logme::ID WebChannel{ "web" };
Logme::SID BillingSubsystem =
  Logme::SID::Build("billing");

static void LoadProfile(int userId)
{
  LogmeI(
    "Loading profile for user %d"
    , userId
  );
}

static void CheckBilling()
{
  LogmeI("Checking billing state");
}

void HandleRequest(
  int userId
  , const char* requestId
)
{
  Logme::ThreadFields fields;

  fields.Set(
    "request_id"
    , requestId
  );

  fields.Set(
    "user_id"
    , std::to_string(userId)
  );

  {
    LogmeThreadChannel(WebChannel);
    LogmeThreadSubsystem(BillingSubsystem);
    LogmeThreadFields(fields);

    LogmeI("Request started");

    LoadProfile(userId);
    CheckBilling();

    LogmeI("Request completed");
  }
}

LoadProfile() does not know anything about requestId. CheckBilling() does not know anything about the web channel. Neither function needs BillingSubsystem as an argument.

That is exactly the point. These values are part of the logging context, not part of the business logic of the nested functions themselves.

As a result, anything running inside that scope can use the current thread-local context when it writes a log entry.

A default channel for the current thread

LogmeThreadChannel(...) sets the default channel for the current thread when a log call does not specify one explicitly.

This is especially useful for shared or library code.

For example:

static void ProcessProtocol()
{
  LogmeI("Protocol initialized");
  LogmeW("Peer response is delayed");
}

This function does not need to know whether it is being called by a client, a server, or a test tool.

The caller can decide:

{
  LogmeThreadChannel(ClientChannel);

  ProcessProtocol();
}

Elsewhere, the same function may run under a different channel:

{
  LogmeThreadChannel(ServerChannel);

  ProcessProtocol();
}

This is also the pattern used in the ThreadContext example included with logme. A worker creates its own channel and installs it as the thread channel.

Meanwhile, library code logs in the usual way without knowing anything about routing.

That is much cleaner than forcing every helper function to accept a ChannelPtr or ID solely because there is one log message somewhere inside it.

The subsystem can travel with the same context

LogmeThreadSubsystem(...) works in a similar way for subsystems.

A channel may represent a broad destination for logs, while a subsystem identifies the specific functional area involved:

{
  LogmeThreadChannel(WebChannel);
  LogmeThreadSubsystem(BillingSubsystem);

  ProcessPayment();
}

Regular log calls within that scope will use the current subsystem unless one is explicitly provided for the individual message.

This matters not only for readability. In logme, subsystem levels participate in filtering. Therefore, a thread-local subsystem affects actual logging behavior and is not just a decorative label.

Therefore, you can increase detail for one problematic area of the system without rewriting every log call inside it.

Thread fields and structured logging

For request processing, background jobs, and correlation across many records, LogmeThreadFields(...) is often the most useful part of the model.

ThreadFields lets you associate a set of name/value pairs with the current thread:

Logme::ThreadFields fields;

fields.Set(
  "request_id"
  , "req-84721"
);

fields.Set(
  "tenant"
  , "customer-a"
);

fields.Set(
  "operation"
  , "load-profile"
);

You can then install that set for the duration of an operation:

{
  LogmeThreadFields(fields);

  LogmeI("Profile loading started");

  LoadProfile();

  LogmeI("Profile loading completed");
}

When structured output is enabled, those values become separate fields in the log record.

A JSON entry may look roughly like this:

{
  "level": "INFO",
  "request_id": "req-84721",
  "tenant": "customer-a",
  "operation": "load-profile",
  "message": "Profile loading started"
}

That is much more useful than building one long text string such as:

request_id=req-84721 tenant=customer-a operation=load-profile Profile loading started

In the structured case, a log collection system can treat request_id and tenant as real fields. You can search by them, filter by them, and correlate all events for one request without depending on text parsing.

In the current logme implementation, thread fields are included in JSON and XML output. However, they are not automatically appended to plain OUTPUT_TEXT records.

That behavior is intentional. ThreadFields is primarily a structured logging feature, not a hidden mechanism for concatenating extra text onto every line.

You do not have to rebuild the full field set every time

For longer-lived contexts, logme also lets you manipulate individual thread fields directly through the logger.

For example:

Logme::Instance->SetThreadField(
  "request_id"
  , requestId
);

Logme::Instance->SetThreadField(
  "tenant"
  , tenant
);

A field can be read with GetThreadField(), removed with RemoveThreadField(), and the full set can be cleared with:

Logme::Instance->ClearThreadFields();

That can be convenient in a worker loop:

void Worker()
{
  for (;;)
  {
    Job job = GetNextJob();

    Logme::Instance->SetThreadField(
      "job_id"
      , job.Id.c_str()
    );

    ProcessJob(job);

    Logme::Instance->ClearThreadFields();
  }
}

The obvious risk is that the thread outlives the job. If you forget to clear the field, the next job may inherit the previous job_id.

For that reason, whenever the lifetime of the context naturally matches a C++ scope, the RAII-style form is usually the safer choice.

The previous context is restored automatically

LogmeThreadChannel, LogmeThreadSubsystem, LogmeThreadFields, and LogmeThreadOverride are built around small RAII helper classes inside logme.

On entry, the current value is saved and a new one is installed. When the helper object is destroyed, the previous value is restored.

That makes nested context changes behave naturally:

{
  LogmeThreadSubsystem(NetworkSubsystem);

  LogmeI("Connection started");

  {
    LogmeThreadSubsystem(TlsSubsystem);

    LogmeI("TLS handshake started");
    PerformHandshake();
  }

  LogmeI("Connection established");
}

After leaving the inner block, NetworkSubsystem becomes active again.

This is useful not only because it is convenient. It also makes restoration reliable. The previous context is restored on early return and during C++ exception unwinding.

One detail is worth noting for LogmeThreadFields(...). A new ThreadFields object temporarily replaces the current set of thread fields.

The previous set is restored afterwards. It does not automatically merge an outer set with an inner one.

If you only want to add or modify one field in an already established context, SetThreadField() is usually the better tool.

Thread override without passing it everywhere

A thread can also carry a current Override.

For example:

Logme::Override override;
override.Remove.Method = true;

{
  LogmeThreadOverride(override);

  ProcessRequest();
}

Nested log calls now inherit that override without the need to pass it through every function.

This is the same pattern used in the official ThreadContext example from the logme sources. A worker assigns a thread channel and a thread override.

Then library code logs normally without any knowledge of those settings.

That illustrates the larger design idea. The logging policy is chosen at the boundary of the operation, not spread through its entire implementation.

Why ThreadName is still useful

A numeric thread ID is useful for machines, but not especially pleasant for people to read. logme therefore allows you to assign a readable thread name:

LogmeThreadName(
  channel
  , "network-worker-3"
);

For long-lived workers this can make logs much easier to scan. Instead of a wall of numeric IDs, you see names such as scheduler, network-worker-3, or db-maintenance.

However, thread names and request_id solve different problems.

A thread name answers:

Which thread executed this code?

A field such as request_id or job_id answers:

Which specific operation was this thread executing?

One worker thread may process thousands of requests during its lifetime. Therefore, thread naming is a complement to structured fields, not a replacement for them.

What happens when work moves to another thread

The phrase thread-local should be taken literally.

The context belongs to the current thread. If work begins in one thread and later continues in another, you should not assume the context automatically follows the task.

This matters in thread pools, task queues, and more complex asynchronous pipelines.

For example, a handler may receive request_id and enqueue work. Later, a free worker thread may pick it up.

In that worker, the relevant context needs to be established again for that execution stage.

So thread-local logging context is very good at removing the need to pass logging-only data down the regular call stack of one thread. However, it is not a universal propagation mechanism across threads.

That distinction is actually useful. logme does not try to guess which task in another thread is a continuation of the current one.

What belongs in logging context

Thread fields should not become a second copy of the full request object.

The best candidates are values that help correlate many log records belonging to the same operation. Typical examples are request_id, connection_id, job_id, and tenant.

Sometimes user_id or the name of the current operation also makes sense.

Data that belongs only to one specific event should usually remain in the message itself.

For example, request_id belongs to the full request:

fields.Set(
  "request_id"
  , requestId
);

while the HTTP status is only known at the end:

LogmeI(
  "Request completed, status=%d"
  , status
);

This keeps the context compact and predictable while still allowing each message to carry its own concrete details.

When thread-local logging context is most useful

The model works best when the application has a clear execution boundary.

A server starts processing a request:

{
  LogmeThreadChannel(WebChannel);
  LogmeThreadFields(requestFields);

  HandleRequest();
}

A worker starts a new job:

{
  LogmeThreadSubsystem(WorkerSubsystem);
  LogmeThreadFields(jobFields);

  ProcessJob();
}

Or shared library code runs on behalf of a particular connection:

{
  LogmeThreadChannel(ConnectionChannel);

  ProtocolLibrary();
}

In all of these cases, lower-level code does not need to know how the application chose to organize its logs.

It simply writes messages.

Logging context should not distort your application design

This is probably the main reason to use thread-local logging context in the first place.

If a value is part of a function’s actual business input, then of course it should be passed in the normal way. Logging context is not a way to hide application data that the function genuinely needs.

However, a request_id, the selected logging channel, or a temporary override is often not part of the function’s business input at all. It exists only for diagnostics.

Passing such values through many layers of APIs means letting the architecture of the application serve the needs of logging infrastructure.

Thread-local logging context avoids that.

At the boundary of a request, a worker, or a job, you establish the relevant channel, subsystem, and structured fields once. After that, the internal code remains normal application code.

At the same time, its log messages still carry the right diagnostic context.

That is exactly what thread-local logging context is for: not to hide application state, but to avoid forcing logging-only state through the entire application.

Leave a Reply