Il y a trois ans, j'ai rejoint une mission sur un système de facturation critique : plusieurs microservices .NET, des centaines de milliers de transactions par jour, et une équipe qui débuggait les incidents en production en cherchant à la main dans des fichiers de logs plats sur des serveurs Azure. Chaque incident prenait entre 2 et 8 heures à diagnostiquer. Le code était solide. Les tests passaient. Mais le système était aveugle en production. C'est sur cette mission que j'ai mis en place une stack d'observabilité complète avec OpenTelemetry, et que j'ai compris pourquoi le monitoring n'est pas un à-côté — c'est une fonctionnalité de premier ordre.

Les 3 piliers de l'observabilité : logs, traces, métriques

Avant de plonger dans le code, il est important de distinguer les trois signaux complémentaires que tout système observable doit émettre :

Les logs structurés

Les logs sont des événements discrets horodatés. La clé est qu'ils soient structurés : pas des chaînes de texte libres, mais des objets JSON avec des propriétés indexables. La différence entre logger.LogInformation("Order {OrderId} processed") et une chaîne concaténée, c'est la possibilité de filtrer, agréger et alerter sur OrderId dans votre backend — Elastic, Azure Monitor, Seq, Loki — sans regex fragile.

Les traces distribuées

Une trace représente le parcours complet d'une requête à travers vos services : de l'API gateway jusqu'au dernier appel base de données. Chaque trace est composée de spans — des unités de travail avec une durée, des attributs et une relation parent-enfant. C'est ce qui vous permet de répondre à "pourquoi cette requête a-t-elle pris 3 secondes ?" en voyant exactement quel appel SQL ou quel appel HTTP externe a bloqué.

Les métriques

Les métriques sont des mesures numériques agrégées dans le temps : nombre de requêtes par seconde, latence p95, taux d'erreur, taille de file de messages, utilisation mémoire. Ce sont elles qui alimentent vos dashboards et vos alertes. Contrairement aux traces, elles sont conçues pour être stockées sur de longues durées avec un coût minimal.

"Un système sans observabilité, c'est un avion sans instruments de bord. Vous pouvez décoller — vous ne saurez pas où vous êtes quand le moteur tousse à 10 000 mètres."

OpenTelemetry .NET : la configuration de base dans Program.cs

OpenTelemetry est devenu le standard de facto pour l'instrumentation. Sa force est d'être vendor-neutral : on instrumente une fois, on change de backend sans toucher au code applicatif. Voici la configuration que j'utilise comme base sur mes missions ASP.NET Core 9 :

// Program.cs
using OpenTelemetry;
using OpenTelemetry.Logs;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;

var builder = WebApplication.CreateBuilder(args);

// Définition de la ressource (identifiant le service dans les backends)
var resourceBuilder = ResourceBuilder.CreateDefault()
    .AddService(
        serviceName: "bff-client-api",
        serviceVersion: Assembly.GetExecutingAssembly()
            .GetCustomAttribute<AssemblyInformationalVersionAttribute>()
            ?.InformationalVersion ?? "unknown",
        serviceInstanceId: Environment.MachineName);

// Traces
builder.Services.AddOpenTelemetry()
    .WithTracing(tracing =>
    {
        tracing
            .SetResourceBuilder(resourceBuilder)
            .AddAspNetCoreInstrumentation(opts =>
            {
                opts.Filter = ctx => ctx.Request.Path != "/health";
                opts.RecordException = true;
            })
            .AddHttpClientInstrumentation()
            .AddEntityFrameworkCoreInstrumentation()
            .AddSource("Edenred.Smarter.Client.Bff.*")
            .AddOtlpExporter(); // vers OpenTelemetry Collector
    })
    // Métriques
    .WithMetrics(metrics =>
    {
        metrics
            .SetResourceBuilder(resourceBuilder)
            .AddAspNetCoreInstrumentation()
            .AddHttpClientInstrumentation()
            .AddRuntimeInstrumentation()
            .AddMeter("Edenred.Smarter.Client.Bff")
            .AddOtlpExporter();
    });

// Logs via ILogger — bridge vers OTel
builder.Logging.AddOpenTelemetry(logging =>
{
    logging.SetResourceBuilder(resourceBuilder);
    logging.IncludeScopes = true;
    logging.IncludeFormattedMessage = true;
    logging.AddOtlpExporter();
});

Notez AddSource("Edenred.Smarter.Client.Bff.*") : c'est ce qui permet d'écouter les spans personnalisés créés dans le code applicatif via ActivitySource. Sans cette ligne, vos spans custom sont ignorés.

Logs structurés avec Serilog : enrichissement contextuel

Sur mes missions, j'utilise Serilog en complément d'OpenTelemetry pour ses enrichers contextuels. La combinaison des deux donne des logs structurés qui incluent automatiquement le TraceId OTel — ce qui permet de passer d'un log à la trace correspondante en un clic dans Grafana ou Application Insights :

// Configuration Serilog avec enrichissement OTel
Log.Logger = new LoggerConfiguration()
    .ReadFrom.Configuration(builder.Configuration)
    .Enrich.FromLogContext()
    .Enrich.WithMachineName()
    .Enrich.WithEnvironmentName()
    .Enrich.WithProperty("Application", "bff-client-api")
    // Enricher OTel : injecte TraceId et SpanId dans chaque log
    .Enrich.WithOpenTelemetryTraceId()
    .WriteTo.Console(new JsonFormatter())
    .WriteTo.OpenTelemetry(opts =>
    {
        opts.Endpoint = "http://otel-collector:4317";
        opts.Protocol = OtlpProtocol.Grpc;
    })
    .CreateLogger();

// Exemple d'usage dans un handler MediatR
public class ExportInvoiceHandler : IRequestHandler<ExportInvoiceQuery, ExportResult>
{
    private readonly ILogger<ExportInvoiceHandler> _logger;

    public async Task<ExportResult> Handle(
        ExportInvoiceQuery request,
        CancellationToken cancellationToken)
    {
        using var activity = ActivitySources.Bff.StartActivity("ExportInvoice");
        activity?.SetTag("invoice.month", request.Month);
        activity?.SetTag("invoice.year", request.Year);
        activity?.SetTag("client.id", request.ClientId);

        _logger.LogInformation(
            "Starting invoice export for client {ClientId}, period {Month}/{Year}",
            request.ClientId, request.Month, request.Year);

        try
        {
            var result = await _invoiceService.ExportAsync(request, cancellationToken);

            activity?.SetTag("invoice.count", result.InvoiceCount);
            _logger.LogInformation(
                "Export completed: {InvoiceCount} invoices, {FileSizeKb}KB",
                result.InvoiceCount, result.FileSizeBytes / 1024);

            return result;
        }
        catch(Exception ex)
        {
            activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
            _logger.LogError(ex,
                "Export failed for client {ClientId}", request.ClientId);
            throw;
        }
    }
}

Spans personnalisés avec ActivitySource

L'ActivitySource est l'API native .NET pour créer des spans de traces personnalisés. Je crée une classe statique centralisée pour tous les sources d'activités du service — cela évite les chaînes magiques éparpillées dans le code :

// ActivitySources.cs — centralisé dans le projet Application
public static class ActivitySources
{
    public static readonly ActivitySource Bff =
        new("Edenred.Smarter.Client.Bff", "1.0.0");

    // Pour chaque sous-domaine fonctionnel
    public static readonly ActivitySource Invoicing =
        new("Edenred.Smarter.Client.Bff.Invoicing", "1.0.0");

    public static readonly ActivitySource Ordering =
        new("Edenred.Smarter.Client.Bff.Ordering", "1.0.0");
}

// Usage dans un service
public async Task<InvoiceSummary> ComputeSummaryAsync(string clientId)
{
    using var span = ActivitySources.Invoicing.StartActivity(
        "ComputeInvoiceSummary",
        ActivityKind.Internal);

    span?.SetTag("client.id", clientId);

    var invoices = await _repo.GetByClientAsync(clientId);
    span?.SetTag("invoice.count", invoices.Count);

    return BuildSummary(invoices);
}

Health checks : l'observabilité de la disponibilité

Les health checks sont souvent traités comme une formalité — un endpoint qui retourne 200. Sur mes missions, je les instrumente pour qu'ils donnent une image précise des dépendances du service :

builder.Services.AddHealthChecks()
    .AddSqlServer(
        connectionString: builder.Configuration.GetConnectionString("Default")!,
        name: "sqlserver",
        tags: new[] { "db", "critical" })
    .AddRabbitMQ(
        rabbitConnectionString: builder.Configuration["MessageBroker:Host"]!,
        name: "rabbitmq",
        tags: new[] { "messaging" })
    .AddUrlGroup(
        uri: new Uri(builder.Configuration["ExternalApi:BaseUrl"]! + "/health"),
        name: "downstream-api",
        tags: new[] { "external" });

// Exposition des endpoints
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
    Predicate = _ => false // liveness : juste le process
});

app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
    Predicate = check => check.Tags.Contains("critical"),
    ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});

app.MapHealthChecks("/health/full", new HealthCheckOptions
{
    ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});

Ce qu'il faut absolument monitorer sur une API .NET

Après plusieurs missions sur des APIs .NET à fort trafic, voici les indicateurs qui me semblent non-négociables pour opérer sereinement :

"Le but n'est pas de collecter plus de données — c'est de poser moins de questions sans réponse en production."

Du logging à l'observabilité : le changement mental

La différence entre un système qui logue et un système qui est observable n'est pas technique — c'est une question de posture. Quand j'arrive en mission et que je parle d'observabilité, j'entends souvent "on a Kibana, on a des logs". C'est nécessaire mais pas suffisant. L'observabilité, c'est la capacité à se poser n'importe quelle question sur le comportement du système sans avoir besoin de redéployer du code pour ajouter un log.

Cela demande de penser à l'instrumentation comme à une API : quelles informations vais-je avoir besoin de questionner dans 6 mois lors d'un incident à 3h du matin ? Les tags sur les spans, les propriétés sur les logs, les labels sur les métriques — ce sont des décisions architecturales, pas des détails d'implémentation.

Depuis que j'intègre cette approche dès le démarrage des missions, les estimations de résolution d'incidents sont passées de "quelques heures" à "quelques minutes" sur les systèmes instrumentés correctement. Ce n'est pas de la magie — c'est simplement avoir les bons instruments de bord.

Votre système de production est-il vraiment observable ?

J'accompagne les équipes .NET dans la mise en place d'une stack d'observabilité exploitable — OpenTelemetry, Serilog, health checks, dashboards. Depuis La Rochelle, en remote ou sur site.

✉ Me contacter