Back to Blog

Why I Built a Logging Library (and what I learned along the way)

2/18/2026

A few months ago I came across an article called Logging Sucks by Boris Tane. I wasn't expecting much (it was a Substack post with a confrontational title and I figured it'd be a rant). But I read it, and it kind of broke my brain in the best way. Tane described exactly the frustration I'd been feeling at work but hadn't been able to articulate. The idea that most logging is just noise. That we spray _logger.LogInformation calls all over the place and then wonder why we can't figure out what happened when something goes wrong.

It stuck with me for a few days. I kept thinking about how many times I'd been debugging production issues and had to piece together what happened from like six different log lines, each one telling me a fraction of the story. User authenticated here. Request started there. Database query over here. Response sent somewhere else. None of it connected. It was a mess.

So I decided to build something. Not because the world needed another logging library (it probably didn't), but because I wanted to see if I could build a logging experience that actually told me something useful when I looked at it.


The Problem

If you've worked on any .NET API for more than a few months, you've probably written something like this a hundred times:

_logger.LogInformation("Request started: {Method} {Path}", method, path);
_logger.LogInformation("User authenticated: {UserId}", userId);
_logger.LogInformation("Fetching user data");
_logger.LogInformation("Processing business logic");
_logger.LogInformation("Request completed with status {Status}", statusCode);

Five log lines for a single request. Each one on its own is almost useless. And the worst part is that in production, under load, these get interleaved with log lines from every other concurrent request. Good luck grepping through that at 2am trying to figure out why a user's order didn't go through.

What Tane's article pushed me toward was the concept of a "wide event." Instead of scattering breadcrumbs across your codebase, you capture everything about a single API call in one structured document. The request, the response, the user, the session, the timing, the trace ID. All of it, one event, one place to look.

So the dream was simple: slap an attribute on a controller and get a complete picture of every request that hits it. No manual log calls, no plumbing, no noise.

[ApiLogging]
public async Task<IActionResult> GetUser(int id)
{
    var user = await _userRepository.GetByIdAsync(id);
    return Ok(user);
}

That's it. That one attribute captures the full request context, the response, the user identity, timing data, trace IDs, all of it written as a single MongoDB document. And if you need to add extra context during processing, you still can. But you don't have to litter your code with log lines just to get basic observability.


The Architecture

Once I had the idea, the first big question was where to store everything. SQL felt wrong almost immediately. Log data is messy and inconsistent. Different endpoints have different payloads, different shapes, different sizes. Trying to normalize that into relational tables sounded miserable. MongoDB's document model was a natural fit. Each API action is a self-contained document with all the context baked in. No joins, no schema migrations when I add a new field.

The second thing I obsessed over was making sure logging never slowed down the actual API. The whole point of this library is that you drop it into an existing app and forget about it. If it added latency to every request, nobody would use it (including me). So I went with a channel-based producer/consumer pattern. The middleware captures the request and response, packages it into an action, and drops it into a bounded Channel<ApiAction>. A background service picks items off the channel in batches and bulk-inserts them into MongoDB. The request pipeline never waits for a database write.

The third decision that I'm probably most proud of is session management. Every team I've worked on does authentication slightly differently. Some use JWT tokens, some rely on cookies or headers, some have custom middleware that stuffs a user ID into HttpContext.Items. I didn't want to force anyone into a single approach, so I built a hybrid strategy system. The library checks JWT claims first, falls back to HttpContext, and supports a fully manual mode. You can configure which claim types, header names, and cookie names map to your user and session IDs.

builder.Services.AddApiLogging(builder.Configuration,
    configureOptions: options =>
    {
        options.IncludeGetRequestLogs = true;
    },
    configureFeatures: flags =>
    {
        flags.EnableHealthChecks = true;
        flags.EnableMetrics = true;
        flags.EnableOpenTelemetry = true;
    });

The setup ended up being a single method call. Configure your connection string and database name in appsettings.json, call AddApiLogging, register the middleware, and you're done. I spent way too long tweaking the developer experience here, but I think it paid off. If the setup is annoying, people won't use the library. Including future me.


The Stuff Nobody Warns You About

Building the core logging was honestly the easy part. The hard part (probably 70% of the total effort) was everything around it. The production concerns, the edge cases, the "what happens when MongoDB goes down at 3am" scenarios.

I added Polly for retry policies and circuit breakers. If MongoDB becomes unreachable, the circuit breaker opens and the library stops hammering it with failed writes. Instead, it falls back to writing JSON lines to a local file so you don't lose data. When Mongo comes back, the circuit closes and everything goes back to normal. I also built a cleanup service that prunes old fallback files so they don't pile up forever.

Then there was GDPR. I didn't think about it at first, but once I realized I was capturing user IDs, IP addresses, request bodies, and session data, I knew I needed a way to export and delete all of that per user. So I built streaming export and deletion APIs. ExportUserDataAsync gives you paginated results of everything tied to a user and DeleteUserDataAsync wipes it all. Right to access and right to be forgotten, both covered.

Sensitive data filtering was another rabbit hole. I couldn't just log raw request bodies because people put passwords, credit card numbers, and SSNs in those. I wrote a sanitizer that checks field names against a configurable list and also runs regex patterns to catch things like card numbers buried in string values. It's not bulletproof, but it catches the obvious stuff and you can extend it with your own patterns.


Creating a Corresponding UI

At some point I had all this data flowing into MongoDB and I realized I had no good way to look at it. Mongo Compass is fine for poking around, but it's not exactly what you'd want to show a client or put in an admin panel. I needed a proper UI.

So I built @millerbyte/react-logging, a set of drop-in React components that sit on top of TanStack Query and TanStack Table. The idea was that if someone is already using the logging library on their backend, they should be able to spin up a dashboard with minimal code. Session lists, action lists, detail views, timelines, analytics, filters, all pre-built and themeable.

import {
  LoggingProvider,
  SessionList,
  createLoggingClient
} from "@millerbyte/react-logging";

const loggingClient = createLoggingClient({
  baseUrl: "/api/logging",
  headers: () => {
    const token = window.localStorage.getItem("token");
    return token ? { Authorization: `Bearer ${token}` } : {};
  }
});

export const SessionsPage = () => (
  <LoggingProvider>
    <SessionList
      fetchSessions={loggingClient.fetchSessions}
      enablePolling
      pollingInterval={15000}
    />
  </LoggingProvider>
);

I designed the query models on the .NET side to be directly compatible with TanStack Table's pagination, sorting, and filtering interfaces. So the React components just pass their table state straight to the API and everything lines up without any weird mapping in between. I'm glad I thought about that early because it made everything else a lot simpler.


Final Thoughts

This project started because I read an article and thought "I could probably build that." It ended up being way more work than I expected (session management, GDPR compliance, circuit breakers, sensitive data filtering, a React component library, a whole testing suite). But I learned more building this than I have on most projects, especially about the gap between "this works on my machine" and "this is ready for production."

It's available as a NuGet package now, and the React components are on npm. If you're building a .NET API and you're tired of scattered log lines that don't tell you anything, maybe give it a shot. Or don't. At the very least, go read Boris Tane's article. It might break your brain too.

Thanks for reading. Still learning out loud over here.