.NET Core
Logging und Monitoring in .NET: Serilog und Application Insights
Fuer produktive .NET-Systeme ist Observability unverzichtbar. Logs allein reichen nicht aus; Logs, Metriken und Traces muessen ein zusammenhaengendes Gesamtbild ergeben. In Produktivsystemen habe ich beobachtet, dass Teams ohne strukturierte Observability 3-5 Mal laenger fuer die Fehleranalyse brauchen als Teams mit einer durchdachten Logging- und Monitoring-Pipeline.
Dieser Artikel behandelt praxiserprobte Muster fuer den Aufbau einer soliden Observability-Grundlage mit Serilog, Application Insights und OpenTelemetry in .NET.
Structured Logging mit Serilog
Warum Structured Logging wichtig ist
Unstrukturierte Logs wie "Benutzer 42 hat Bestellung 1001 aufgegeben" sind fuer Menschen lesbar, aber fuer Maschinen unbrauchbar. Bei tausenden Requests pro Sekunde ueber mehrere Services hinweg braucht man Logs, die durchsuchbar, filterbar und aggregierbar sind.
Structured Logging erfasst Daten als Key-Value-Paare neben der Nachricht und ermoeglicht dadurch datenbankaehnliche Abfragen auf Logs.
Serilog-Konfiguration
Eine produktionsreife Serilog-Konfiguration geht weit ueber die Standardeinstellungen hinaus. Hier ist die Basis-Konfiguration, die ich in den meisten .NET-Services verwende:
using Serilog;
using Serilog.Events;
using Serilog.Sinks.SystemConsole.Themes;
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore.Hosting", LogEventLevel.Warning)
.MinimumLevel.Override("System", LogEventLevel.Warning)
.Enrich.FromLogContext()
.Enrich.WithMachineName()
.Enrich.WithEnvironmentName()
.Enrich.WithProperty("ServiceName", "OrderApi")
.WriteTo.Console(theme: AnsiConsoleTheme.Code)
.WriteTo.ApplicationInsights(
telemetryConfiguration,
TelemetryConverter.Traces)
.WriteTo.Async(a => a.File(
path: "logs/order-api-.log",
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 14,
fileSizeLimitBytes: 100_000_000))
.CreateLogger();Registrierung in Program.cs:
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseSerilog();
var app = builder.Build();
app.UseSerilogRequestLogging(options =>
{
options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
{
diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value);
diagnosticContext.Set("UserAgent", httpContext.Request.Headers["User-Agent"].ToString());
};
});Message Templates richtig einsetzen
Serilog verwendet Message Templates statt String-Interpolation. Dieser Unterschied ist entscheidend:
// FALSCH - String-Interpolation zerstoert die Struktur
_logger.LogInformation($"Bestellung {orderId} von Benutzer {userId} erstellt");
// RICHTIG - Message Template bewahrt benannte Properties
_logger.LogInformation("Bestellung {OrderId} von Benutzer {UserId} erstellt", orderId, userId);
// RICHTIG - komplexe Objekte mit @ destrukturieren
_logger.LogInformation("Bestellung aufgegeben: {@BestellDetails}", new { orderId, userId, total, itemCount });Mit dem richtigen Ansatz kann man spaeter im Log-Aggregator WHERE OrderId = 1001 abfragen. Bei String-Interpolation geht diese Property im Textblock verloren.
Enricher und kontextbezogenes Logging
Enricher fuegen automatisch Properties zu jedem Log-Eintrag innerhalb eines Scopes hinzu. Das ist unersetzlich fuer die Nachverfolgung eines Requests ueber seinen gesamten Lebenszyklus:
public class CorrelationIdMiddleware
{
private readonly RequestDelegate _next;
public CorrelationIdMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
var correlationId = context.Request.Headers["X-Correlation-ID"].FirstOrDefault()
?? Guid.NewGuid().ToString();
context.Response.Headers["X-Correlation-ID"] = correlationId;
using (LogContext.PushProperty("CorrelationId", correlationId))
{
await _next(context);
}
}
}In Produktivsystemen habe ich beobachtet, dass ohne Correlation IDs die Fehlersuche bei service-uebergreifenden Problemen zum Ratespiel wird. Eine einzelne Correlation ID, die vom API-Gateway durch jeden nachgelagerten Service-Aufruf fliesst, ist die wirkungsvollste Observability-Investition, die man machen kann.
Eigene Enricher entwickeln
Fuer wiederkehrende Kontextinformationen lohnt sich ein wiederverwendbarer Enricher:
public class TenantEnricher : ILogEventEnricher
{
private readonly IHttpContextAccessor _httpContextAccessor;
public TenantEnricher(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
{
var tenantId = _httpContextAccessor.HttpContext?.Items["TenantId"]?.ToString();
if (!string.IsNullOrEmpty(tenantId))
{
logEvent.AddPropertyIfAbsent(
propertyFactory.CreateProperty("TenantId", tenantId));
}
}
}Best Practices fuer Structured Logging
Das richtige Log-Level verwenden
Jedes Level hat seinen Zweck. Falscher Einsatz erzeugt entweder Rauschen oder verbirgt kritische Informationen:
- Verbose/Trace: Interne Framework-Details. In Produktion fast nie aktiviert.
- Debug: Diagnostische Details fuer die Entwicklung. Bei Fehlersuche pro Service aktivieren.
- Information: Geschaeftsrelevante Ereignisse. "Bestellung erstellt", "Zahlung verarbeitet", "Benutzer angemeldet".
- Warning: Etwas Unerwartetes ist passiert, aber das System hat sich erholt. Retry erfolgreich, Cache-Miss-Fallback.
- Error: Eine Operation ist fehlgeschlagen. Der Request konnte nicht abgeschlossen werden, aber der Service laeuft weiter.
- Fatal/Critical: Der Prozess steht kurz vor dem Absturz. Nicht wiederherstellbarer Zustand, Speichermangel, korrupte Konfiguration.
// Gut: klares Geschaeftsereignis auf Information-Level
_logger.LogInformation("Zahlung {PaymentId} fuer Bestellung {OrderId} verarbeitet, Betrag {Amount:C}",
paymentId, orderId, amount);
// Gut: Warning fuer behobenes Problem
_logger.LogWarning("Cache-Miss fuer Produkt {ProductId}, Fallback auf Datenbank", productId);
// Gut: Error mit vollstaendigem Exception-Kontext
_logger.LogError(ex, "Zahlung {PaymentId} fuer Bestellung {OrderId} fehlgeschlagen", paymentId, orderId);Log-Volumen unter Kontrolle halten
In Produktivsystemen habe ich beobachtet, dass Services ueber 50 GB Logs pro Tag erzeugten, weil Entwickler jede Schleifeniteration auf Information-Level geloggt hatten. Hohes Log-Volumen kostet Geld, verlangsamt Abfragen und vergraebt das Signal im Rauschen.
Faustregeln:
- Einstieg/Ausstieg von Geschaeftsoperationen loggen, nicht jeden Methodenaufruf
- Debug-Level fuer alles verwenden, was nur bei aktiver Untersuchung benoetigt wird
- In engen Schleifen nicht loggen; stattdessen aggregieren und eine Zusammenfassung loggen
- Minimum-Level-Overrides fuer geschwatzige Framework-Namespaces setzen
Konsistente Property-Benennung
Namenskonventionen im Team vereinbaren und durchsetzen:
// Inkonsistent - Albtraum beim Abfragen
_logger.LogInformation("Verarbeitet fuer Benutzer {user_id}", userId);
_logger.LogInformation("Verarbeitet fuer Benutzer {UserId}", userId);
_logger.LogInformation("Verarbeitet fuer Benutzer {uid}", userId);
// Konsistent - eine Konvention waehlen (PascalCase empfohlen) und beibehalten
_logger.LogInformation("Verarbeitet fuer Benutzer {UserId}", userId);Application Insights Integration
Request- und Dependency-Tracking
Application Insights bietet automatische Telemetrie fuer HTTP-Requests, SQL-Aufrufe und externe Abhaengigkeiten:
builder.Services.AddApplicationInsightsTelemetry(options =>
{
options.ConnectionString = builder.Configuration["ApplicationInsights:ConnectionString"];
options.EnableAdaptiveSampling = true;
});
// Eigene Telemetrie fuer Geschaeftsmetriken
public class OrderService
{
private readonly TelemetryClient _telemetry;
public OrderService(TelemetryClient telemetry)
{
_telemetry = telemetry;
}
public async Task<Order> PlaceOrderAsync(OrderRequest request)
{
var stopwatch = Stopwatch.StartNew();
try
{
var order = await ProcessOrder(request);
_telemetry.TrackEvent("OrderPlaced", new Dictionary<string, string>
{
["OrderId"] = order.Id.ToString(),
["ItemCount"] = request.Items.Count.ToString()
}, new Dictionary<string, double>
{
["OrderTotal"] = (double)order.Total,
["ProcessingTimeMs"] = stopwatch.ElapsedMilliseconds
});
return order;
}
catch (Exception ex)
{
_telemetry.TrackException(ex, new Dictionary<string, string>
{
["Operation"] = "PlaceOrder",
["UserId"] = request.UserId.ToString()
});
throw;
}
}
}Adaptive Sampling
Bei hohem Traffic-Volumen ist es teuer und unnoetig, jeden Trace an Application Insights zu senden. Adaptive Sampling haelt die Kosten beherrschbar und bewahrt gleichzeitig die Fehlersichtbarkeit:
builder.Services.Configure<TelemetryConfiguration>(config =>
{
var processor = config.DefaultTelemetrySink.TelemetryProcessorChainBuilder;
processor.UseAdaptiveSampling(
maxTelemetryItemsPerSecond: 5,
excludedTypes: "Exception;Event");
processor.Build();
});Die zentrale Erkenntnis: Exceptions und kritische Events immer vom Sampling ausschliessen. Man will 100% Erfassung von Fehlern, unabhaengig vom Traffic-Volumen.
Distributed Tracing einrichten
OpenTelemetry-Integration
OpenTelemetry ist der herstellerneutrale Standard fuer Distributed Tracing. In .NET bietet die Kombination mit Serilog das Beste aus beiden Welten:
builder.Services.AddOpenTelemetry()
.ConfigureResource(resource => resource
.AddService(
serviceName: "OrderApi",
serviceVersion: "1.0.0"))
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation(options =>
{
options.Filter = httpContext =>
!httpContext.Request.Path.StartsWithSegments("/health");
})
.AddHttpClientInstrumentation()
.AddSqlClientInstrumentation(options =>
{
options.SetDbStatementForText = true;
options.RecordException = true;
})
.AddSource("OrderApi.Activities")
.AddOtlpExporter(options =>
{
options.Endpoint = new Uri("http://otel-collector:4317");
}))
.WithMetrics(metrics => metrics
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation()
.AddMeter("OrderApi.Metrics")
.AddOtlpExporter());Eigene Activity-Spans
Fuer geschaeftskritische Operationen erstellt man eigene Spans, um Timing und Kontext zu erfassen:
public class OrderProcessor
{
private static readonly ActivitySource ActivitySource = new("OrderApi.Activities");
public async Task<Order> ProcessAsync(OrderRequest request)
{
using var activity = ActivitySource.StartActivity("ProcessOrder");
activity?.SetTag("order.item_count", request.Items.Count);
activity?.SetTag("order.user_id", request.UserId);
var inventory = await CheckInventory(request.Items);
activity?.AddEvent(new ActivityEvent("InventoryChecked"));
var payment = await ProcessPayment(request);
activity?.AddEvent(new ActivityEvent("PaymentProcessed"));
var order = await SaveOrder(request, payment);
activity?.SetTag("order.id", order.Id);
activity?.SetTag("order.total", order.Total);
return order;
}
}Logs mit Traces verbinden
Die wahre Staerke von Distributed Tracing entfaltet sich, wenn Logs mit Trace-Spans verknuepft sind. Serilog kann automatisch Trace- und Span-IDs einfuegen:
Log.Logger = new LoggerConfiguration()
.Enrich.WithProperty("ServiceName", "OrderApi")
.Enrich.With<ActivityEnricher>() // fuegt TraceId und SpanId hinzu
.CreateLogger();
public class ActivityEnricher : ILogEventEnricher
{
public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
{
var activity = Activity.Current;
if (activity != null)
{
logEvent.AddPropertyIfAbsent(
propertyFactory.CreateProperty("TraceId", activity.TraceId.ToString()));
logEvent.AddPropertyIfAbsent(
propertyFactory.CreateProperty("SpanId", activity.SpanId.ToString()));
}
}
}Wenn man nun einen Fehler-Log findet, kann man direkt zum vollstaendigen Trace in der Tracing-Oberflaeche springen und jeden Service-Aufruf sehen, der zum Fehler gefuehrt hat.
Alarm-Strategie
SLO-basiertes Alerting vs. Schwellwert-Spam
In Produktivsystemen habe ich beobachtet, dass Teams ueber 200 aktive Alarme hatten, die alle ignorierten. Alarm-Muedigkeit ist ein reales Problem und entsteht durch Alerting auf Roh-Schwellwerte statt auf das, was wirklich zaehlt: die Service Level Objectives.
Schwellwert-basiertes Alerting (problematisch):
- CPU > 80% loest Alarm aus (aber der Service bewaeltigt die Last problemlos)
- Antwortzeit > 500ms loest Alarm aus (aber es war ein einzelner Ausreisser)
- Jeder 5xx loest Alarm aus (aber die Fehlerrate liegt bei 0,001%)
SLO-basiertes Alerting (effektiv):
- Error-Budget-Verbrauchsrate uebersteigt 2x in der letzten Stunde
- 99. Perzentil Latenz verletzt SLO fuer 10 aufeinanderfolgende Minuten
- Verfuegbarkeit faellt unter 99,9% im rollierenden 1-Stunden-Fenster
SLO-Alarme implementieren
// Eigene Metrik fuer SLO-Tracking
public class SloMetrics
{
private static readonly Meter Meter = new("OrderApi.Metrics");
private static readonly Counter<long> RequestCounter =
Meter.CreateCounter<long>("http_requests_total");
private static readonly Counter<long> ErrorCounter =
Meter.CreateCounter<long>("http_errors_total");
private static readonly Histogram<double> LatencyHistogram =
Meter.CreateHistogram<double>("http_request_duration_seconds");
public void RecordRequest(string endpoint, int statusCode, double durationSeconds)
{
var tags = new TagList
{
{ "endpoint", endpoint },
{ "status_code", statusCode.ToString() }
};
RequestCounter.Add(1, tags);
LatencyHistogram.Record(durationSeconds, tags);
if (statusCode >= 500)
{
ErrorCounter.Add(1, tags);
}
}
}Im Alerting-System (Azure Monitor, Grafana usw.) definiert man dann Burn-Rate-Alarme:
- Page (jemanden wecken): Error-Budget-Verbrauchsrate > 14,4x ueber 1 Stunde (verbraucht 2% des Monatsbudgets in 1 Stunde)
- Ticket (naechster Werktag): Error-Budget-Verbrauchsrate > 3x ueber 6 Stunden
- Informativ: Error-Budget-Verbrauchsrate > 1x ueber 3 Tage
Dieser Ansatz bedeutet, dass man nur geweckt wird, wenn die Situation das SLO tatsaechlich gefaehrdet -- nicht bei jedem transienten Ausschlag.
Haeufige Logging-Fehler
1. Sensible Daten loggen
Der gefaehrlichste Fehler, und er passiert haeufiger als man denkt:
// NIEMALS so machen
_logger.LogInformation("Benutzer-Login: {Email}, Passwort: {Password}", email, password);
_logger.LogInformation("Zahlung mit Karte {CardNumber} verarbeitet", cardNumber);
_logger.LogDebug("API-Aufruf mit Token {AuthToken}", authToken);
// SICHERE Alternativen
_logger.LogInformation("Benutzer-Login: {Email}", email); // Passwort komplett weggelassen
_logger.LogInformation("Zahlung mit Karte endend auf {CardLast4} verarbeitet", cardNumber[^4..]);
_logger.LogDebug("API-Aufruf fuer Benutzer {UserId} authentifiziert", userId);Eine Destructuring-Policy hinzufuegen, um versehentliche Offenlegung sensibler Daten abzufangen:
Log.Logger = new LoggerConfiguration()
.Destructure.ByTransforming<UserCredentials>(
creds => new { creds.Email, Password = "***REDACTED***" })
.CreateLogger();2. Fehlender Correlation-Kontext
Ohne Correlation IDs ist ein Log-Eintrag wie "Zahlung fehlgeschlagen" in einem verteilten System nahezu nutzlos. Man kann ihn weder mit dem ausloesenden Request, dem betroffenen Benutzer noch mit dem verursachenden Upstream-Service verbinden.
Correlation-Kontext immer ueber HTTP-Header und Message-Queue-Properties propagieren.
3. Logging in heissen Pfaden
// SCHLECHT - Logging fuer jedes Element in einem 10.000er Batch
foreach (var item in items)
{
_logger.LogInformation("Verarbeite Element {ItemId}", item.Id);
await Process(item);
}
// BESSER - Batch-Zusammenfassung loggen
_logger.LogInformation("Verarbeite Batch mit {ItemCount} Elementen", items.Count);
var results = await ProcessBatch(items);
_logger.LogInformation("Batch abgeschlossen: {SuccessCount} erfolgreich, {FailCount} fehlgeschlagen",
results.Successes, results.Failures);4. Exceptions verschlucken
// SCHLECHT - Exception-Information geht komplett verloren
try { await ProcessOrder(order); }
catch (Exception ex)
{
_logger.LogError("Etwas ist schiefgelaufen");
return StatusCode(500);
}
// GUT - vollstaendige Exception mit Kontext bewahren
try { await ProcessOrder(order); }
catch (Exception ex)
{
_logger.LogError(ex, "Bestellung {OrderId} fuer Benutzer {UserId} konnte nicht verarbeitet werden",
order.Id, order.UserId);
return StatusCode(500);
}5. Keine Log-Aufbewahrungsrichtlinie
Unbegrenzt gespeicherte Logs sind sowohl ein Compliance-Risiko als auch ein Kostenproblem. Aufbewahrungsstufen definieren:
- Hot Storage (letzte 7 Tage): Volltextsuche, schnelle Abfragen
- Warm Storage (7-30 Tage): Langsamere Abfragen, geringere Kosten
- Cold Storage (30-90 Tage): Nur Archiv, Compliance-Aufbewahrung
- Loeschung nach Ablauf der Aufbewahrungsfrist
Sicherheit und Compliance
- Niemals Secrets, Tokens, Passwoerter oder personenbezogene Daten ueber das Notwendige hinaus loggen
- Aufbewahrungsrichtlinien an Compliance-Anforderungen ausrichten (DSGVO, SOC 2, branchenspezifische Vorgaben)
- Zugriffskontrolle sicherstellen, sodass Entwickler nur Logs ihrer eigenen Services abfragen koennen
- Zugriff auf Produktions-Logs auditieren
- Logs bei der Uebertragung und im Ruhezustand verschluesseln
Fazit
Hochwertige Observability verkuerzt die MTTR (Mean Time To Recovery) erheblich und verwandelt Incident Response von Ratespiel in einen wiederholbaren Engineering-Prozess. Die Investition in Structured Logging, Distributed Tracing und SLO-basiertes Alerting amortisiert sich beim ersten Mal, wenn man ein Produktionsproblem in Minuten statt Stunden diagnostiziert.
Mit Structured Logging und Correlation IDs beginnen. Distributed Tracing hinzufuegen, wenn mehrere Services im Spiel sind. SLO-basierte Alarme von Tag eins einrichten. Diese Reihenfolge liefert bei jedem Schritt den hoechsten Mehrwert.
Gerne unterstuetze ich beim Aufbau einer belastbaren Observability-Basis fuer Ihre .NET-Plattform.
Verwandte Artikel
RESTful APIs mit ASP.NET Core entwickeln
Lernen Sie die Grundlagen für produktionsreife REST-APIs mit ASP.NET Core. Controller, Routing und Best Practices.
Microservices-Architektur mit .NET: Design und Umsetzung
Entwerfen Sie Microservices-Architektur mit .NET. Service-Kommunikation und Orchestrierung.
.NET Performance-Optimierung: Profiling und Best Practices
Optimieren Sie die Performance von .NET-Anwendungen. Profiling, Memory Management und Async-Patterns.
Haben Sie ein Flutter-Projekt?
Ich entwickle hochleistungsfähige Flutter-Anwendungen für iOS, Android und Web.
Kontakt aufnehmen