Bridging log4net to OpenTelemetry: Why and How
Bridging log4net to OpenTelemetry: Why and How
We recently faced an interesting challenge at work: we had applications built on log4net that needed to send logs to our new OpenTelemetry-based observability stack. The question wasn’t whether to do it, but how to do it without rewriting half our codebase.
This is the story of why we needed a bridge, why it was the practical choice, and how log4net’s extensibility through custom appenders made it surprisingly straightforward.
The Situation
Picture this: you have multiple applications, some dating back to .NET Framework 4.x days, all using log4net. They work. They’re stable. They’ve been in production for years. The logging statements are scattered throughout the codebase—controllers, services, repositories, utility classes—everywhere.
Now your organization is experimenting with OpenTelemetry. Your new observability platform (Seq, in my local test case) speaks OTLP. Your new services use Microsoft.Extensions.Logging with OpenTelemetry. Everything is modern and standardized.
Except for those legacy apps.
Why Not Just Rewrite?
The obvious answer is “replace log4net with ILogger.” But let’s think about what that actually means:
Code Changes Everywhere: Every file that logs anything needs changes. That’s potentially thousands of files across multiple projects. Each change is a risk of introducing bugs.
Testing Burden: You need to test every code path that logs. Not just unit tests—you need to make sure logs still flow correctly in production scenarios, error handling works, performance is acceptable.
No Business Value: You’re changing working code to… do the same thing it already does. Just with a different logging library. Try explaining that ROI to your manager.
Risk vs. Reward: The risk of breaking something is high. The reward is… different log statements? It’s a tough sell.
Why a Forwarding Appender Made Sense
log4net has always been extensible through appenders. An appender is just a destination for logs—file, database, console, whatever. The beautiful thing is that log4net already handles all the hard parts: collecting logs, buffering, filtering, formatting. It just needs to know where to send them.
So instead of ripping out log4net, we asked: “Can we just teach log4net to speak OpenTelemetry?”
The answer is yes. And it’s cleaner than you might think.
Zero Application Code Changes: The logging statements stay exactly as they are. log.Info(), log.Error(), everything works unchanged.
Configuration-Only Change: You modify App.config or Web.config to add a new appender. That’s it. Deploy, restart, done.
Incremental Migration: You can add the OTLP appender alongside existing appenders. Logs go to both old and new destinations during transition.
Correlation Built-In: By tapping into Activity.Current, we get automatic correlation with distributed traces. This is the real win—logs and traces tied together.
How log4net Appenders Work
Before diving into the OpenTelemetry specifics, let’s look at how you build a custom log4net appender. It’s actually pretty simple.
Every appender inherits from AppenderSkeleton and implements one key method: Append(LoggingEvent loggingEvent). That’s where your log goes.
Here’s a minimal example:
using log4net.Appender;
using log4net.Core;
public class SimpleFileAppender : AppenderSkeleton
{
public string FilePath { get; set; }
protected override void Append(LoggingEvent loggingEvent)
{
var message = $"{loggingEvent.TimeStamp:yyyy-MM-dd HH:mm:ss} [{loggingEvent.Level}] {loggingEvent.RenderedMessage}";
File.AppendAllText(FilePath, message + Environment.NewLine);
}
}
And you configure it in App.config:
<appender name="MyFileAppender" type="MyNamespace.SimpleFileAppender, MyAssembly">
<filePath value="C:\logs\app.log" />
</appender>
log4net handles the rest—reading the config, instantiating your appender, setting the FilePath property, and calling Append() for every log event.
The LoggingEvent object contains everything you need:
Level- Debug, Info, Warn, Error, FatalRenderedMessage- The formatted log messageExceptionObject- Any exception that was loggedLoggerName- Which logger created this eventThreadName- Which thread logged itTimeStamp- When it happenedProperties- Any additional context data
Building the OpenTelemetry Bridge
So if we can write an appender that dumps logs to a file, why not an appender that forwards logs to Microsoft.Extensions.Logging.ILogger? That ILogger can then be configured with OpenTelemetry exporters, and boom—log4net logs going to OTLP.
The architecture ended up being simple:
The Abstract Base: ForwardOpenTelemetryLog4NetAppender
This base class handles the forwarding logic:
public abstract class ForwardOpenTelemetryLog4NetAppender : AppenderSkeleton
{
private ILoggerFactory _loggerFactory;
private Microsoft.Extensions.Logging.ILogger _forwardLogger;
public string ServiceName { get; set; }
public string ServiceVersion { get; set; }
public override void ActivateOptions()
{
// Called once during initialization
_loggerFactory = CreateLoggerFactory();
_forwardLogger = _loggerFactory.CreateLogger(Name);
base.ActivateOptions();
}
// Derived classes implement this to create the specific logger factory
protected abstract ILoggerFactory CreateLoggerFactory();
protected override void Append(LoggingEvent loggingEvent)
{
var level = MapLevel(loggingEvent.Level);
// Add correlation context as a scope
var scopeState = new[]
{
new KeyValuePair<string, object>("traceId", Activity.Current?.TraceId.ToString()),
new KeyValuePair<string, object>("spanId", Activity.Current?.SpanId.ToString()),
new KeyValuePair<string, object>("thread", loggingEvent.ThreadName),
new KeyValuePair<string, object>("logger", loggingEvent.LoggerName)
};
using (var scope = _forwardLogger.BeginScope(scopeState))
{
if (loggingEvent.ExceptionObject != null)
{
_forwardLogger.Log(level, loggingEvent.ExceptionObject, loggingEvent.RenderedMessage);
}
else
{
_forwardLogger.Log(level, loggingEvent.RenderedMessage);
}
}
}
private static LogLevel MapLevel(Level level)
{
if (level == Level.Fatal) return LogLevel.Critical;
if (level == Level.Error) return LogLevel.Error;
if (level == Level.Warn) return LogLevel.Warning;
if (level == Level.Info) return LogLevel.Information;
if (level == Level.Debug) return LogLevel.Debug;
return LogLevel.Trace;
}
}
The clever part here is the use of Activity.Current. In .NET, Activity is the built-in distributed tracing primitive. If your application is using OpenTelemetry tracing (or even just ASP.NET Core’s built-in activity tracking), Activity.Current gives you the current trace and span IDs. By capturing these in a logging scope, the OpenTelemetry log exporter automatically correlates logs with traces.
This means if you’re debugging a slow request, you can see both the trace spans AND all the logs that happened during that request. Correlation without any extra work.
The Concrete Implementation: OTLP Export
Now we need a concrete class that actually creates a logger factory configured for OpenTelemetry:
public class OtlpOpenTelemetryLog4NetAppender : ForwardOpenTelemetryLog4NetAppender
{
public string Protocol { get; set; }
public string Endpoint { get; set; }
public string Headers { get; set; }
protected override ILoggerFactory CreateLoggerFactory()
{
return LoggerFactory.Create(builder =>
{
builder.AddOpenTelemetry(logging =>
{
logging.SetResourceBuilder(CreateResourceBuilder());
logging.IncludeFormattedMessage = true;
logging.ParseStateValues = true;
logging.IncludeScopes = true;
logging.AddOtlpExporter(configure =>
{
configure.Protocol = GetOtelProtocol();
if (!string.IsNullOrWhiteSpace(Endpoint))
{
configure.Endpoint = new Uri(Endpoint);
}
if (!string.IsNullOrWhiteSpace(Headers))
{
configure.Headers = Headers;
}
});
});
});
}
private OtlpExportProtocol GetOtelProtocol()
{
if (Protocol?.Equals("grpc", StringComparison.OrdinalIgnoreCase) == true)
{
return OtlpExportProtocol.Grpc;
}
return OtlpExportProtocol.HttpProtobuf;
}
}
This is where the real magic happens. We’re creating a standard Microsoft.Extensions.Logging logger factory, adding OpenTelemetry to it, and configuring the OTLP exporter. The OpenTelemetry SDK handles all the batching, retries, and protocol details.
Configuration in App.config looks like this:
<log4net>
<appender name="OtlpOpenTelemetry"
type="SomeOrganization.Telemetry.Log4net.Appenders.OtlpOpenTelemetryLog4NetAppender, SomeOrganization.Telemetry.Log4net">
<protocol value="http/protobuf" />
<endpoint value="http://localhost:5341/ingest/otlp/v1/logs" />
<headers value="X-Seq-ApiKey=YourApiKeyHere" />
<serviceName value="MyLegacyApp" />
<serviceVersion value="1.0.0" />
<resourceAttributes value="environment=production,team=platform" />
</appender>
<root>
<level value="INFO" />
<appender-ref ref="OtlpOpenTelemetry" />
</root>
</log4net>
This configuration points at a Seq instance, but works with any OTLP-compatible backend—Jaeger, Grafana Loki, commercial APMs, whatever.
Why This Works So Well
Separation of Concerns: log4net handles log collection (filtering, buffering, formatting). OpenTelemetry handles export (batching, retries, protocol). The appender just translates between them.
Automatic Batching: The OpenTelemetry OTLP exporter batches logs by default. So even if your app logs 1000 times per second, you’re not making 1000 HTTP requests. The exporter intelligently batches and sends them efficiently.
Graceful Shutdown: When LogManager.Shutdown() is called, the logger factory is disposed, which triggers OpenTelemetry to flush any remaining logs. You don’t lose logs during shutdown.
Resource Attributes: The serviceName, serviceVersion, and resourceAttributes get attached to every log as OpenTelemetry resource attributes. This is how you filter and group logs in your observability platform.
No Performance Impact: The forwarding is asynchronous (thanks to OpenTelemetry’s batching). Your application doesn’t block waiting for logs to be sent.
The Practical Benefits We Saw
After experimenting with this in some legacy apps, here’s what we got:
Unified Observability: All our apps—new and old—now send logs to the same place. No more jumping between different logging systems.
Correlation: When a distributed request spans old log4net apps and new ILogger apps, we can follow the entire flow through trace IDs. This was impossible before.
Zero Code Changes: The applications don’t know anything changed. We updated configs and redeployed. That’s it.
Easy Rollback: If something goes wrong, we can disable the OTLP appender and fall back to the old file-based appenders instantly.
Incremental Migration: Some apps send logs to both OTLP and files during transition. No big-bang migration required.
A Note on Implementation
You might be wondering about error handling in the appender. What happens if the OTLP endpoint is down? What if there’s a network issue?
The OpenTelemetry SDK handles this. It has built-in retries, circuit breakers, and fallback behavior. If logs can’t be sent, they’re buffered (up to a limit) and retried. If the buffer fills up, the oldest logs are dropped. This prevents your app from crashing or hanging because of logging issues.
You should still have a fallback appender (like a file appender) configured, especially in production. Log to both OTLP and a local file, so if the network is down, you still have logs somewhere.
Teaching Old Dogs New Tricks
This project reinforced something I’ve learned over the years: sometimes the best solution isn’t the “proper” one. The “proper” solution would be migrating to ILogger. But the practical solution—the one that delivers value quickly with minimal risk—was building a bridge.
log4net’s extensibility through custom appenders made this possible. By implementing AppenderSkeleton and forwarding to ILogger, we got the best of both worlds: no code changes in legacy apps, but modern OpenTelemetry observability.
If you’re in a similar situation—legacy logging that needs to talk to modern infrastructure—consider the bridge approach. It’s less glamorous than a full rewrite, but it works, it’s safe, and it buys you time to do the proper migration on your own schedule.
Key Takeaways
-
Custom log4net appenders are simple: Inherit from
AppenderSkeleton, implementAppend(), done. -
Forwarding to ILogger works: Create an
ILoggerFactory, configure it with OpenTelemetry, forward log4net events to it. -
Correlation is critical: Capture
Activity.Currentto tie logs to distributed traces. -
Config-only changes are low risk: No code changes means no new bugs in business logic.
-
Bridges buy time: You get immediate value while planning a proper long-term solution.
Sometimes the best engineering solution is the one that ships quickly and works reliably.