.NET · La plateforme

Configuration et journalisation

Sources de configuration, patron Options, journaux structurés.

Vérifié en septembre 2026 · .NET 10 (SDK 10.0.300), C# 14 · environ 15 min

Une application lit des réglages qu'elle ne doit pas contenir en dur — un délai, une adresse, une chaîne de connexion — et écrit des journaux qu'on ne lira que le jour où elle se comporte mal. .NET confie les deux à deux bibliothèques jumelles : Microsoft.Extensions.Configuration empile des sources en un seul dictionnaire de clés, Microsoft.Extensions.Logging filtre chaque message puis le remet à des fournisseurs. WebApplication.CreateBuilder et Host.CreateApplicationBuilder branchent l'une et l'autre avant la première ligne de code métier, et tout ce qui suit passe par le conteneur d'injection, dont le cours Injection de dépendances explique les durées de vie qu'on retrouve ici. Les exemples tournent sur le runtime .NET 10.0.8 installé, la version courante au 26 septembre 2026 étant la 10.0.12. Les programmes console sont compilés avec le SDK web, qui apporte toutes ces bibliothèques ; un projet console ordinaire ajoute le paquet Microsoft.Extensions.Hosting (10.0.12, comme dans le cours Injection de dépendances). L'exemple sans point d'entrée est une classe statique dont un Program.cs appelle Executer.

Les sources et leur ordre

La configuration n'est pas un fichier : c'est une pile de fournisseurs. Chacun aplatit sa source en paires clé-valeur dont la hiérarchie s'écrit avec des deux-points, Facturation:DelaiJours, et à la lecture le dernier fournisseur qui connaît la clé l'emporte. CreateBuilder empile, du plus faible au plus fort : appsettings.json, appsettings.{Environnement}.json, les secrets utilisateur quand l'environnement est Development, les variables d'environnement, la ligne de commande. L'ordre suit une intention : le fichier du dépôt porte les défauts, et le déploiement surcharge sans le toucher.

// appsettings.json :
//   { "Facturation": { "Devise": "EUR", "DelaiJours": 30, "Emetteur": "Atelier Martin", "CleApi": "a-definir" } }
// appsettings.Development.json :
//   { "Facturation": { "Emetteur": "Atelier Martin (test)" } }
// Un secret utilisateur (le .csproj porte un <UserSecretsId>) :
//   dotnet user-secrets set "Facturation:CleApi" "cle-factice-de-test"
// Lancement, depuis PowerShell :
//   $env:ASPNETCORE_ENVIRONMENT = "Development"; $env:Facturation__DelaiJours = "45"
//   dotnet run -- --Facturation:Devise=CHF

var builder = WebApplication.CreateBuilder(args);
var racine = (IConfigurationRoot)builder.Configuration;

foreach (var reglage in builder.Configuration.GetSection("Facturation").GetChildren())
{
    // Le dernier fournisseur qui connait la cle l'emporte.
    var source = racine.Providers.Last(fournisseur => fournisseur.TryGet(reglage.Path, out _));
    Console.WriteLine($"{reglage.Path} = {reglage.Value}  <- {source}");
}

// Facturation:CleApi = cle-factice-de-test  <- JsonConfigurationProvider for 'secrets.json' (Optional)
// Facturation:DelaiJours = 45  <- EnvironmentVariablesConfigurationProvider
// Facturation:Devise = CHF  <- CommandLineConfigurationProvider
// Facturation:Emetteur = Atelier Martin (test)  <- JsonConfigurationProvider for 'appsettings.Development.json' (Optional)
//
// Sans variable ni argument, et sans profil de lancement (ce projet n'a pas de
// Properties/launchSettings.json ; celui d'un template fixe Development, et
// dotnet run --no-launch-profile l'ignore) : on est en Production, sans secrets,
// et les quatre valeurs viennent de JsonConfigurationProvider for 'appsettings.json'.

Chaque réglage vient ici d'une source différente. Une variable d'environnement remplace les deux-points par un double souligné, Facturation__DelaiJours, parce que les deux-points ne sont pas admis dans un nom de variable sur toutes les plateformes, bash en tête. L'environnement se lit dans ASPNETCORE_ENVIRONMENT ou DOTNET_ENVIRONMENT pour le builder web, dans la seule DOTNET_ENVIRONMENT pour Host.CreateApplicationBuilder, vaut Production quand rien ne le fixe, et choisit le second fichier. Les secrets utilisateur, écrits par dotnet user-secrets, vivent hors du dépôt, dans %APPDATA%\Microsoft\UserSecrets sous Windows : ils évitent de commiter une clé de développement, mais le fichier est en clair et n'est lu qu'en Development. En production, un secret vient d'une variable d'environnement ou d'un coffre qui a son fournisseur, comme Azure Key Vault. Le cours Construire une API donne le même ordre en commentaire de son démarrage minimal ; il omet une source discrète, que la documentation de configuration ne cite pas non plus au 26 septembre 2026 : depuis .NET 10, l'hôte lit aussi {NomDeLApplication}.settings.json et sa variante par environnement, juste après les deux appsettings. Ils servent aux applications en un seul fichier, lancées par dotnet run app.cs : plusieurs peuvent partager un dossier et garder chacune ses réglages, les appsettings restant communs. Le code de Microsoft.Extensions.Hosting 10.0 et les remarques de sa référence d'API les mentionnent, la liste des fournisseurs les affiche.

Lire la configuration : clés, sections, liaison

IConfiguration, que le conteneur fournit à qui le demande, se lit de trois manières : l'indexeur rend une chaîne, GetValue<T> la convertit, GetSection descend d'un niveau. Get<T> lie une section entière sur les propriétés publiques d'un objet, un tableau JSON devenant une liste par ses indices, Relances:0 puis Relances:1.

using System;
using System.Collections.Generic;
using Microsoft.Extensions.Configuration;

sealed class OptionsFacturation
{
    public string Devise { get; set; } = "EUR";
    public int DelaiJours { get; set; }
    public List<string> Relances { get; set; } = [];
}

static class Lecture
{
    public static void Executer()
    {
        // Ce que fait chaque fournisseur : aplatir sa source en cles separees
        // par des deux-points. Un fichier JSON donnerait exactement ces cles.
        IConfiguration configuration = new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?>
            {
                ["Facturation:Devise"] = "CHF",
                ["Facturation:DelaiJours"] = "45",
                ["Facturation:Relances:0"] = "J+7",
                ["Facturation:Relances:1"] = "J+15",
            })
            .Build();

        // L'indexeur rend toujours une chaine, ou null.
        Console.WriteLine(configuration["Facturation:DelaiJours"]); // 45

        // Les cles ne tiennent pas compte de la casse.
        Console.WriteLine(configuration["facturation:devise"]); // CHF

        var section = configuration.GetSection("Facturation");
        Console.WriteLine(section.GetValue<int>("DelaiJours") + 1); // 46

        // Une faute de frappe ne leve rien : la valeur par defaut du type.
        Console.WriteLine(section.GetValue<int>("DelaiJour")); // 0

        // Une section absente non plus : GetSection ne rend jamais null.
        Console.WriteLine(configuration.GetSection("Facturaton").Exists()); // False

        // Get<T> lie toute la section a un objet ; un tableau se lie a une liste.
        var options = section.Get<OptionsFacturation>()!;
        Console.WriteLine($"{options.Devise}, {options.DelaiJours} jours, {string.Join(" puis ", options.Relances)}");
        // CHF, 45 jours, J+7 puis J+15

        try
        {
            configuration.GetRequiredSection("Facturaton");
        }
        catch (InvalidOperationException erreur)
        {
            Console.WriteLine(erreur.Message);
            // Section 'Facturaton' not found in configuration.
        }

        var fautive = new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?>
            {
                ["Facturation:DelaiJours"] = "trente",
            })
            .Build()
            .GetSection("Facturation");

        try
        {
            fautive.Get<OptionsFacturation>();
        }
        catch (InvalidOperationException erreur)
        {
            Console.WriteLine(erreur.Message);
            // Failed to convert configuration value 'trente' at
            // 'Facturation:DelaiJours' to type 'System.Int32'.
        }

        try
        {
            new ConfigurationBuilder()
                .AddInMemoryCollection(new Dictionary<string, string?> { ["Facturation:Delai"] = "30" })
                .Build()
                .GetSection("Facturation")
                .Get<OptionsFacturation>(liaison => liaison.ErrorOnUnknownConfiguration = true);
        }
        catch (InvalidOperationException erreur)
        {
            Console.WriteLine(erreur.Message);
            // 'ErrorOnUnknownConfiguration' was set on the provided BinderOptions, but
            // the following properties were not found on the instance of
            // OptionsFacturation: 'Delai'
        }
    }
}

Le point faible est le silence : GetSection ne rend jamais null, et une faute de frappe dans une clé donne un zéro ou un null sans exception. Trois garde-fous existent. GetRequiredSection lève sur une section absente ; une valeur inconvertible lève à la liaison ; ErrorOnUnknownConfiguration refuse une clé qui ne correspond à aucune propriété. Aucun ne remarque qu'une propriété n'a rien reçu : c'est le rôle de la validation. Et une classe métier qui lit IConfiguration répand des chaînes de clés dans tout le code ; le patron Options les regroupe en un seul endroit.

Le patron Options : trois interfaces, trois comportements

Le patron Options fait d'une section une classe, liée par le framework, injectée comme n'importe quel service. On l'enregistre par Configure<T>(section) ou AddOptions<T>().BindConfiguration("Section"), et le consommateur demande l'une de trois interfaces. Elles diffèrent par leur durée de vie et par ce qu'elles mettent en cache, donc par le moment où elles voient une modification du fichier : IOptions et IOptionsMonitor sont toutes deux des singletons, mais la première garde la valeur calculée une fois, la seconde l'invalide à chaque rechargement.

InterfaceDurée de vieValeur
IOptions<T>singletoncalculée à la première lecture de Value, jamais recalculée
IOptionsSnapshot<T>scopedrecalculée une fois par portée, donc par requête
IOptionsMonitor<T>singletonCurrentValue suit chaque rechargement ; OnChange prévient
using System;
using System.IO;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;

static string Fichier(int delai) => $$"""{ "Facturation": { "Devise": "EUR", "DelaiJours": {{delai}} } }""";

File.WriteAllText("facturation.json", Fichier(30));

var builder = Host.CreateApplicationBuilder(args);
builder.Configuration.AddJsonFile("facturation.json", optional: false, reloadOnChange: true);

// Configure lie la section a OptionsFacturation, a la premiere demande.
builder.Services.Configure<OptionsFacturation>(builder.Configuration.GetSection("Facturation"));

using var hote = builder.Build();
var racine = hote.Services;

var options = racine.GetRequiredService<IOptions<OptionsFacturation>>();
var moniteur = racine.GetRequiredService<IOptionsMonitor<OptionsFacturation>>();
moniteur.OnChange(nouvelles => Console.WriteLine($"OnChange : {nouvelles.DelaiJours}"));

Afficher("avant");

File.WriteAllText("facturation.json", Fichier(60));
await Task.Delay(TimeSpan.FromSeconds(1)); // le temps que le fichier soit relu

Afficher("apres");

void Afficher(string moment)
{
    // Une portee, comme une requete HTTP.
    using var portee = racine.CreateScope();
    var instantane = portee.ServiceProvider.GetRequiredService<IOptionsSnapshot<OptionsFacturation>>();
    Console.WriteLine($"{moment} : IOptions {options.Value.DelaiJours}, " +
        $"IOptionsSnapshot {instantane.Value.DelaiJours}, IOptionsMonitor {moniteur.CurrentValue.DelaiJours}");
}

sealed class OptionsFacturation
{
    public string Devise { get; set; } = "EUR";
    public int DelaiJours { get; set; }
}

// avant : IOptions 30, IOptionsSnapshot 30, IOptionsMonitor 30
// OnChange : 60   <- deux fois ici, sous Windows : le nombre d'appels depend
// OnChange : 60      des notifications du systeme de fichiers
// apres : IOptions 30, IOptionsSnapshot 60, IOptionsMonitor 60

Les fichiers JSON que CreateBuilder ajoute sont chargés avec reloadOnChange ; les variables d'environnement et la ligne de commande, elles, ne sont lues qu'au démarrage. OnChange a été appelé deux fois pour une seule écriture, sous Windows ; le nombre d'appels dépend des notifications du système de fichiers, et un rappel de rechargement doit supporter d'être rejoué. Une même classe peut aussi servir plusieurs sections sous des noms, Configure<OptionsBanque>("Secours", section), que IOptionsMonitor et IOptionsSnapshot retrouvent par Get("Secours") ; IOptions ne connaît que l'option sans nom, et sa Value n'a que les valeurs par défaut si seules des options nommées sont liées. Parce qu'il est scoped, IOptionsSnapshot ne se donne pas à un singleton : en Development, le démarrage échoue sur Cannot consume scoped service 'Microsoft.Extensions.Options.IOptionsSnapshot`1[OptionsFacturation]' from singleton, la dépendance captive que détaille le cours Injection de dépendances. Un singleton qui doit suivre la configuration reçoit IOptionsMonitor, à condition de ne pas faire ce qui suit : copier CurrentValue dans un champ à la construction, ce qui fige la valeur du démarrage aussi sûrement qu'IOptions.

using System;
using System.IO;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;

static string Fichier(int delai) => $$"""{ "Facturation": { "DelaiJours": {{delai}} } }""";

File.WriteAllText("facturation.json", Fichier(30));

var builder = Host.CreateApplicationBuilder(args);
builder.Configuration.AddJsonFile("facturation.json", optional: false, reloadOnChange: true);
builder.Services.Configure<OptionsFacturation>(builder.Configuration.GetSection("Facturation"));
builder.Services.AddSingleton<Echeancier>();

using var hote = builder.Build();
var echeancier = hote.Services.GetRequiredService<Echeancier>();
var emission = new DateOnly(2026, 9, 1);

Console.WriteLine(echeancier.Echeance(emission).ToString("yyyy-MM-dd")); // 2026-10-01

File.WriteAllText("facturation.json", Fichier(60));
await Task.Delay(TimeSpan.FromSeconds(1));

Console.WriteLine(echeancier.Echeance(emission).ToString("yyyy-MM-dd")); // 2026-10-01

sealed class OptionsFacturation
{
    public int DelaiJours { get; set; }
}

// Singleton : construit une fois, il copie la valeur du moment et la garde.
sealed class Echeancier(IOptionsMonitor<OptionsFacturation> options)
{
    private readonly int delaiJours = options.CurrentValue.DelaiJours;

    public DateOnly Echeance(DateOnly emission) => emission.AddDays(delaiJours);
}

Le moniteur se garde, et c'est à chaque appel qu'on lui demande la valeur courante :

using System;
using System.IO;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;

static string Fichier(int delai) => $$"""{ "Facturation": { "DelaiJours": {{delai}} } }""";

File.WriteAllText("facturation.json", Fichier(30));

var builder = Host.CreateApplicationBuilder(args);
builder.Configuration.AddJsonFile("facturation.json", optional: false, reloadOnChange: true);
builder.Services.Configure<OptionsFacturation>(builder.Configuration.GetSection("Facturation"));
builder.Services.AddSingleton<Echeancier>();

using var hote = builder.Build();
var echeancier = hote.Services.GetRequiredService<Echeancier>();
var emission = new DateOnly(2026, 9, 1);

Console.WriteLine(echeancier.Echeance(emission).ToString("yyyy-MM-dd")); // 2026-10-01

File.WriteAllText("facturation.json", Fichier(60));
await Task.Delay(TimeSpan.FromSeconds(1));

Console.WriteLine(echeancier.Echeance(emission).ToString("yyyy-MM-dd")); // 2026-10-31

sealed class OptionsFacturation
{
    public int DelaiJours { get; set; }
}

// Le singleton garde le moniteur, et lui demande la valeur a chaque appel.
sealed class Echeancier(IOptionsMonitor<OptionsFacturation> options)
{
    public DateOnly Echeance(DateOnly emission) => emission.AddDays(options.CurrentValue.DelaiJours);
}

Valider les options au démarrage

Une option liée n'est pas une option juste. Sans validation, une clé mal orthographiée ou oubliée dans un fichier de déploiement donne la valeur par défaut de la propriété, et l'application fonctionne, mal. Ici, l'échéance tombe le jour de l'émission.

using Microsoft.Extensions.Options;

var builder = WebApplication.CreateBuilder(args);

// appsettings.json : { "Facturation": { "DelaiJour": 45 } }
// La propriete s'appelle DelaiJours, et Emetteur n'est pas renseigne.
builder.Services.Configure<OptionsFacturation>(builder.Configuration.GetSection("Facturation"));

var app = builder.Build();

app.MapGet("/echeance", (IOptions<OptionsFacturation> options) =>
    new { echeance = new DateOnly(2026, 9, 1).AddDays(options.Value.DelaiJours), emetteur = options.Value.Emetteur });

app.Run();

sealed class OptionsFacturation
{
    public int DelaiJours { get; set; }
    public string? Emetteur { get; set; }
}

// GET /echeance -> 200 {"echeance":"2026-09-01","emetteur":null}
// Ni exception ni avertissement : une facture payable le jour meme, sans emetteur.
using System.ComponentModel.DataAnnotations;
using Microsoft.Extensions.Options;

var builder = WebApplication.CreateBuilder(args);

// Le meme appsettings.json : { "Facturation": { "DelaiJour": 45 } }
builder.Services.AddOptions<OptionsFacturation>()
    .BindConfiguration(OptionsFacturation.Section)
    .ValidateDataAnnotations()
    .Validate(options => options.Emetteur != "a-definir", "Emetteur n'a pas ete renseigne.")
    .ValidateOnStart();

var app = builder.Build();

app.MapGet("/echeance", (IOptions<OptionsFacturation> options) =>
    new { echeance = new DateOnly(2026, 9, 1).AddDays(options.Value.DelaiJours), emetteur = options.Value.Emetteur });

app.Run();

sealed class OptionsFacturation
{
    public const string Section = "Facturation";

    [Range(1, 90)]
    public int DelaiJours { get; set; }

    [Required]
    public string? Emetteur { get; set; }
}

// app.Run() leve avant d'ouvrir le port. Debut du journal (le message est coupe
// ici en plusieurs lignes, la pile d'appels suit) :
// fail: Microsoft.Extensions.Hosting.Internal.Host[11]
//       Hosting failed to start
//       Microsoft.Extensions.Options.OptionsValidationException: DataAnnotation
//       validation failed for 'OptionsFacturation' members: 'DelaiJours' with the
//       error: 'The field DelaiJours must be between 1 and 90.'.; DataAnnotation
//       validation failed for 'OptionsFacturation' members: 'Emetteur' with the
//       error: 'The Emetteur field is required.'.

AddOptions rend un constructeur qui enchaîne liaison et règles. ValidateDataAnnotations applique les attributs de System.ComponentModel.DataAnnotations, Validate ajoute une règle écrite en lambda, et ValidateOnStart les exécute au démarrage de l'hôte. L'application refuse alors de démarrer, et le message nomme chaque propriété fautive : c'est au déploiement qu'on veut l'apprendre. Sans ValidateOnStart, la validation reste paresseuse : elle s'exécute à la première lecture de Value, donc à la première requête, qui répond 500 pendant que le journal note une OptionsValidationException. Les attributs ne descendent pas seuls dans un objet imbriqué : une propriété marquée [ValidateObjectMembers] est validée à son tour, une propriété non marquée ne l'est pas. Deux formes de validateur existent encore, pour deux usages. Une règle qui a besoin d'un service s'écrit à la main dans une classe qui implémente IValidateOptions<T>, enregistrée dans le conteneur. À l'inverse, une classe partial vide marquée [OptionsValidator] reçoit d'un générateur de code la validation des annotations, écrite sans réflexion, ce qui compte pour une publication AOT.

ILogger<T>, catégories, niveaux et filtres

Une classe reçoit un ILogger<T> par injection ; sa catégorie est le nom complet de T, espace de noms compris. Chaque message porte l'un de six niveaux, du plus bavard au plus grave : Trace, Debug, Information, Warning, Error, Critical ; None sert seulement à tout couper. Les filtres se lisent dans la section Logging de la configuration, ici l'appsettings.json du projet :

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Facturation": "Debug",
      "Facturation.Export": "Warning"
    },
    "Console": {
      "LogLevel": {
        "Facturation.Relances": "Error"
      }
    }
  }
}
using System;
using System.Linq;
using Facturation;
using Facturation.Export;
using Facturation.Relances;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddSingleton<ServiceFactures>();
builder.Services.AddSingleton<ExportComptable>();
builder.Services.AddSingleton<Relanceur>();

using var hote = builder.Build();

// Les fournisseurs que l'hote branche sans qu'on les demande (ici sous Windows).
Console.WriteLine(string.Join(", ", hote.Services.GetServices<ILoggerProvider>().Select(f => f.GetType().Name)));
// ConsoleLoggerProvider, DebugLoggerProvider, EventSourceLoggerProvider, EventLogLoggerProvider

hote.Services.GetRequiredService<ServiceFactures>().Emettre();
hote.Services.GetRequiredService<ExportComptable>().Exporter();
hote.Services.GetRequiredService<Relanceur>().Relancer();

// Une categorie n'est qu'une chaine : ILogger<T> prend le nom complet de T.
var sql = hote.Services.GetRequiredService<ILoggerFactory>().CreateLogger("Microsoft.EntityFrameworkCore.Database.Command");
sql.LogInformation("requete executee");
sql.LogWarning("requete lente");

// IsEnabled dit si au moins un fournisseur garderait ce niveau.
var journal = hote.Services.GetRequiredService<ILogger<ServiceFactures>>();
Console.WriteLine(journal.IsEnabled(LogLevel.Debug)); // True
Console.WriteLine(journal.IsEnabled(LogLevel.Trace)); // False

namespace Facturation
{
    public sealed class ServiceFactures(ILogger<ServiceFactures> journal)
    {
        public void Emettre()
        {
            journal.LogTrace("trace");
            journal.LogDebug("debug");
            journal.LogInformation("information");
        }
    }
}

namespace Facturation.Export
{
    public sealed class ExportComptable(ILogger<ExportComptable> journal)
    {
        public void Exporter()
        {
            journal.LogInformation("information");
            journal.LogWarning("warning");
        }
    }
}

namespace Facturation.Relances
{
    public sealed class Relanceur(ILogger<Relanceur> journal)
    {
        public void Relancer()
        {
            journal.LogWarning("warning");
            journal.LogError("error");
        }
    }
}

// Ce que la console affiche du journal :
// dbug: Facturation.ServiceFactures[0]
//       debug
// info: Facturation.ServiceFactures[0]
//       information
// warn: Facturation.Export.ExportComptable[0]
//       warning
// fail: Facturation.Relances.Relanceur[0]
//       error
// warn: Microsoft.EntityFrameworkCore.Database.Command[0]
//       requete lente

Pour chaque couple fournisseur et catégorie, une seule règle s'applique. Une règle propre au fournisseur, sous Logging:Console, passe avant les règles générales ; parmi les candidates, le plus long préfixe de catégorie l'emporte, et Default ne sert qu'à défaut. Facturation ouvre donc le Debug au service de factures, Facturation.Export le referme à Warning pour l'export, et la console seule ne garde que les erreurs des relances. Le premier critère a un revers : un Logging:Console:LogLevel:Default à Warning fait taire, pour la console, le Debug accordé à Facturation, alors que IsEnabled(LogLevel.Debug) reste vrai : les autres fournisseurs, eux, le gardent encore. Quand rien n'est configuré, le niveau minimal est Information. Le préfixe Microsoft à Warning fait taire les bibliothèques du framework ; le jour où l'on veut lire le SQL qu'EF Core envoie, une règle plus longue, Microsoft.EntityFrameworkCore.Database.Command à Information, rouvre cette seule catégorie. Un fichier par environnement peut ouvrir le Debug en Development sans toucher à la production.

Journaux structurés : le modèle de message

Un journal structuré n'enregistre pas une phrase, mais un modèle et des valeurs nommées, que l'outil qui les collecte (Seq, Elastic, Application Insights, un collecteur OpenTelemetry) indexe et filtre : toutes les factures d'un montant supérieur à mille euros, toutes les occurrences d'un même message. L'interpolation détruit cette structure avant même l'appel.

using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

var builder = Host.CreateApplicationBuilder(args);

// Le formateur JSON montre ce qu'un journal structure recoit vraiment.
builder.Logging.ClearProviders();
builder.Logging.AddJsonConsole(options => options.JsonWriterOptions = new JsonWriterOptions { Indented = true });
builder.Services.AddSingleton<ServiceFactures>();

using var hote = builder.Build();
hote.Services.GetRequiredService<ServiceFactures>().Emettre("F-2026-0412", 1250.50m);

sealed class ServiceFactures(ILogger<ServiceFactures> journal)
{
    public void Emettre(string numero, decimal montant)
    {
        // L'interpolation fabrique la chaine avant l'appel : le journal ne
        // recoit qu'un texte, deja formate.
        journal.LogInformation($"Facture {numero} emise pour {montant} EUR");
    }
}

// Sous une culture fr-FR :
// {
//   "EventId": 0,
//   "LogLevel": "Information",
//   "Category": "ServiceFactures",
//   "Message": "Facture F-2026-0412 emise pour 1250,50 EUR",
//   "State": {
//     "{OriginalFormat}": "Facture F-2026-0412 emise pour 1250,50 EUR"
//   }
// }

Le modèle de message garde le texte constant et passe les valeurs à part :

using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

var builder = Host.CreateApplicationBuilder(args);
builder.Logging.ClearProviders();
builder.Logging.AddJsonConsole(options => options.JsonWriterOptions = new JsonWriterOptions { Indented = true });
builder.Services.AddSingleton<ServiceFactures>();

using var hote = builder.Build();
hote.Services.GetRequiredService<ServiceFactures>().Emettre("F-2026-0412", 1250.50m);

sealed class ServiceFactures(ILogger<ServiceFactures> journal)
{
    public void Emettre(string numero, decimal montant)
    {
        // Une chaine constante, des trous nommes, les valeurs a part.
        journal.LogInformation("Facture {NumeroFacture} emise pour {Montant} EUR", numero, montant);
    }
}

// Sous la meme culture fr-FR :
// {
//   "EventId": 0,
//   "LogLevel": "Information",
//   "Category": "ServiceFactures",
//   "Message": "Facture F-2026-0412 emise pour 1250.50 EUR",
//   "State": {
//     "NumeroFacture": "F-2026-0412",
//     "Montant": 1250.50,
//     "{OriginalFormat}": "Facture {NumeroFacture} emise pour {Montant} EUR"
//   }
// }

Les propriétés NumeroFacture et Montant existent désormais, avec leur type, et {OriginalFormat} garde le modèle, ce qui regroupe toutes les occurrences d'un même message. Les trous se remplissent par position et non par nom : les noms ne servent qu'à baptiser les propriétés, et un ordre d'arguments inversé passe sans erreur. Le message rendu formate les valeurs en culture invariante, d'où le point décimal là où l'interpolation écrivait une virgule. L'analyseur CA2254 signale l'interpolation, mais au niveau suggestion en .NET 10 : elle ne casse aucune compilation. Une exception se passe en premier argument, LogError(erreur, "…"), et non dans le texte : le fournisseur garde alors son type et sa pile d'appels.

Des méthodes de journal générées : [LoggerMessage]

Même bien écrit, LogDebug("…", i, montant) coûte à chaque appel un tableau params object[] et la mise en boîte de chaque valeur, que le niveau soit actif ou non. L'attribut [LoggerMessage], posé sur une méthode partial, confie à un générateur de code l'écriture d'une méthode typée qui vérifie IsEnabled avant tout travail.

using System;
using Microsoft.Extensions.Logging;

using var fabrique = LoggerFactory.Create(journaux => journaux
    .AddSimpleConsole()
    .SetMinimumLevel(LogLevel.Information));

var journal = fabrique.CreateLogger<ServiceFactures>();
var service = new ServiceFactures(journal);

// Debug est sous le niveau minimal : aucun de ces appels n'ecrit rien.
Console.WriteLine($"interpolation : {Mesurer(i => journal.LogDebug($"Ligne {i} calculee pour {i * 1.5m} EUR"))} octets");
Console.WriteLine($"LogDebug      : {Mesurer(i => journal.LogDebug("Ligne {Ligne} calculee pour {Montant} EUR", i, i * 1.5m))} octets");
Console.WriteLine($"genere        : {Mesurer(i => service.LigneCalculee(i, i * 1.5m))} octets");
// interpolation : 1274608 octets
// LogDebug      : 960000 octets
// genere        : 0 octets

service.Emettre("F-2026-0412", 1250.50m);
// info: ServiceFactures[1001]
//       Facture F-2026-0412 emise pour 1250.50 EUR

static long Mesurer(Action<int> appel)
{
    for (var i = 0; i < 1_000; i++) appel(i); // echauffement
    var avant = GC.GetAllocatedBytesForCurrentThread();
    for (var i = 0; i < 10_000; i++) appel(i);
    return GC.GetAllocatedBytesForCurrentThread() - avant;
}

// partial : le generateur ecrit le corps des methodes marquees.
sealed partial class ServiceFactures(ILogger<ServiceFactures> journal)
{
    public void Emettre(string numero, decimal montant)
    {
        // ... emission de la facture ...
        FactureEmise(numero, montant);
    }

    [LoggerMessage(EventId = 1001, Level = LogLevel.Information, Message = "Facture {NumeroFacture} emise pour {Montant} EUR")]
    private partial void FactureEmise(string numeroFacture, decimal montant);

    [LoggerMessage(EventId = 1002, Level = LogLevel.Debug, Message = "Ligne {Ligne} calculee pour {Montant} EUR")]
    public partial void LigneCalculee(int ligne, decimal montant);
}

Dix mille appels sous le niveau minimal allouent 1 274 608 octets par interpolation, 960 000 par méthode d'extension, soit 96 par appel, et zéro par la méthode générée. Le générateur écrit ce qu'on écrivait autrefois à la main avec LoggerMessage.Define : un délégué construit une seule fois, le modèle analysé une seule fois. La méthode fixe aussi le niveau et l'identifiant d'événement de chaque message en un seul endroit, ce qui en fait un catalogue qu'on relit. Une méthode d'instance trouve le journal dans un champ ILogger ou, depuis .NET 9, dans un paramètre de constructeur principal, comme ici ; une méthode statique le reçoit parmi ses paramètres, en premier et marqué this si l'on veut une méthode d'extension. Les appels de LogInformation restent acceptables sur un chemin froid ; sur un chemin chaud, dans une boucle ou par requête, la méthode générée est la forme recommandée. L'analyseur CA1848 le rappelle à chaque méthode d'extension, mais il est désactivé par défaut en .NET 10.

Les portées de journalisation

Une portée attache des propriétés à tous les messages écrits pendant sa durée, par quelque classe que ce soit. BeginScope prend un modèle de message et ses valeurs, et rend un IDisposable : la portée se ferme avec le using.

using System.Text.Json;

var builder = WebApplication.CreateBuilder(args);

builder.Logging.ClearProviders();
builder.Logging.AddJsonConsole(options =>
{
    options.IncludeScopes = true;
    options.JsonWriterOptions = new JsonWriterOptions { Indented = true };
});
builder.Logging.AddFilter("Microsoft", LogLevel.Warning);
builder.Services.AddSingleton<Expedition>();
builder.Services.AddSingleton<ServiceFactures>();

var app = builder.Build();

app.MapPost("/factures/{numero}", (string numero, ServiceFactures factures) => factures.Emettre(numero));

app.Run();

sealed class ServiceFactures(Expedition expedition, ILogger<ServiceFactures> journal)
{
    public IResult Emettre(string numero)
    {
        // Tout ce qui est journalise pendant le using porte NumeroFacture,
        // y compris par les classes appelees, qui n'en savent rien.
        using (journal.BeginScope("Emission de {NumeroFacture}", numero))
        {
            expedition.Envoyer();
        }
        return Results.Accepted();
    }
}

sealed class Expedition(ILogger<Expedition> journal)
{
    public void Envoyer() => journal.LogInformation("Facture envoyee");
}

// POST /factures/F-2026-0412 -> 202, et une seule entree de journal.
// Les identifiants (SpanId, TraceId, ConnectionId) changent a chaque execution.
// {
//   "EventId": 0,
//   "LogLevel": "Information",
//   "Category": "Expedition",
//   "Message": "Facture envoyee",
//   "State": {
//     "{OriginalFormat}": "Facture envoyee"
//   },
//   "Scopes": [
//     {
//       "Message": "SpanId:a0829f8704dd05d0, TraceId:1c2cd4a26c04f2aa80e53c13a09267f2, ParentId:0000000000000000",
//       "SpanId": "a0829f8704dd05d0",
//       "TraceId": "1c2cd4a26c04f2aa80e53c13a09267f2",
//       "ParentId": "0000000000000000"
//     },
//     {
//       "Message": "ConnectionId:0HNORITUOIV1O",
//       "ConnectionId": "0HNORITUOIV1O"
//     },
//     {
//       "Message": "RequestPath:/factures/F-2026-0412 RequestId:0HNORITUOIV1O:00000001",
//       "RequestId": "0HNORITUOIV1O:00000001",
//       "RequestPath": "/factures/F-2026-0412"
//     },
//     {
//       "Message": "Emission de F-2026-0412",
//       "NumeroFacture": "F-2026-0412",
//       "{OriginalFormat}": "Emission de {NumeroFacture}"
//     }
//   ]
// }

Expedition n'a jamais vu le numéro de facture, et son message le porte. Les trois portées qui précèdent viennent de la requête. Kestrel et l'hébergement d'ASP.NET Core ouvrent celles de la connexion, du chemin et de l'identifiant de requête. La première, SpanId, TraceId, ParentId, est ajoutée par la fabrique de journaux elle-même, d'après l'Activity qu'ASP.NET Core démarre pour chaque requête : les défauts de l'hôte générique règlent ses ActivityTrackingOptions sur ces trois identifiants. Ensemble, elles suffisent à retrouver toutes les lignes d'une requête parmi des milliers d'autres. Les fournisseurs n'ont pas tous l'obligation de les écrire : la console ne les affiche que si IncludeScopes vaut true, ce qui n'est pas son défaut. Une portée se garde courte et ciblée, un identifiant métier plutôt qu'un objet entier, et jamais un secret : tout ce qu'elle contient part avec chaque message.

Ce cours vous a servi ? Offrir un café Signaler une erreur