Observabilité
Journaux structurés, OpenTelemetry, métriques.
Vérifié en septembre 2026 · outils aux versions citées dans le cours · environ 16 min
Une application en production ne se débogue pas : on n'y pose pas de point d'arrêt, et le problème du jour — une page lente pour certains clients, des erreurs qui montent depuis le dernier déploiement — ne se reproduit pas sur un poste. L'observabilité est la capacité de répondre à ces questions après coup, à partir de ce que l'application émet d'elle-même en continu : des journaux, des traces, des métriques. OpenTelemetry en est le standard ouvert : un vocabulaire commun, les conventions sémantiques, un protocole, OTLP, et un SDK par langage. En .NET, les API d'émission font partie du framework et OpenTelemetry ne fait que les collecter et les exporter, si bien que le code métier ne référence aucun de ses paquets. Les exemples bâtis sur le seul framework ont tourné sur .NET 10 (runtime 10.0.8). Ceux du SDK OpenTelemetry et du collecteur, qui n'étaient pas disponibles hors ligne, suivent leur documentation et leur code source : SDK 1.19.1 du 21 septembre 2026, collecteur v0.161.0 du 14 septembre 2026.
Trois signaux, trois questions
Chaque signal répond à une question différente, et aucun ne remplace les deux autres.
| Signal | Question | Forme | Coût |
|---|---|---|---|
| Journal | Que s'est-il passé, exactement, à ce moment-là ? | un événement horodaté et ses propriétés | suit le trafic ; on filtre par niveau |
| Trace | Par où cette requête est-elle passée, et où a-t-elle perdu son temps ? | un arbre de spans minutés, à travers les services | suit le trafic ; on échantillonne |
| Métrique | Combien, à quel rythme, et est-ce que ça change ? | des mesures agrégées sur un intervalle | fixe, tant que les étiquettes restent bornées |
using System.Diagnostics;
using System.Diagnostics.Metrics;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<ServiceCommandes>();
var app = builder.Build();
app.MapPost("/commandes/{numero:int}/validation", (int numero, ServiceCommandes commandes) =>
{
commandes.Valider(numero);
return Results.NoContent();
});
app.Run();
sealed class ServiceCommandes(IMeterFactory fabrique, ILogger<ServiceCommandes> journal)
{
// Trace : par ou cette commande est-elle passee, et ou le temps s'est-il perdu ?
private static readonly ActivitySource Source = new("Boutique.Commandes");
// Metrique : combien de commandes par minute, et la tendance ?
private readonly Counter<long> _validees = fabrique
.Create("Boutique.Commandes")
.CreateCounter<long>("boutique.commandes.validees", "{commande}");
public void Valider(int numero)
{
using var activite = Source.StartActivity("ValiderCommande");
activite?.SetTag("commande.numero", numero);
// Journal : que s'est-il passe, exactement, pour cette commande-la ?
journal.LogInformation("Commande {Numero} validee", numero);
_validees.Add(1);
}
} Pendant un incident, ils se relaient : une métrique déclenche l'alerte, la latence au 99e centile a doublé ; une trace montre quel appel ralentit ; les journaux de ce span disent pourquoi. Le relais suppose des identifiants partagés, que pose la propagation du contexte. Chaque signal a son API dans .NET : ILogger pour les journaux, ActivitySource pour les traces, Meter pour les métriques. Tant que personne ne les écoute, une source et un meter ne coûtent presque rien, ce qui permet à une bibliothèque de s'instrumenter sans rien imposer à qui l'utilise. Les journaux structurés, leurs niveaux et leurs portées sont le sujet du cours « Configuration et journalisation » ; celui-ci n'en retient que le lien avec les traces.
Tracer : ActivitySource et Activity
Une trace est un arbre de spans : chaque span nomme une opération et porte son début, sa durée, des attributs, des événements et un statut. .NET appelle le span Activity et sa fabrique ActivitySource, nommée comme le composant qui l'émet. StartActivity démarre l'activité et en fait Activity.Current ; le using l'arrête, ce qui fixe sa durée. Activity.Current suit le flux asynchrone comme un AsyncLocal : une activité démarrée plus bas dans la pile d'appels, même après un await, devient son enfant sans qu'on lui passe rien.
Le piège tient à la valeur de retour. StartActivity rend null quand aucun écouteur ne s'intéresse à la source : pas de SDK configuré, un test, ou une source oubliée dans la configuration d'OpenTelemetry. Ce code suppose qu'une activité existe toujours :
using System.Diagnostics;
new ServiceCommandes().Valider(42);
Console.WriteLine("Commande validee");
sealed class ServiceCommandes
{
private static readonly ActivitySource Source = new("Boutique.Commandes");
public void Valider(int numero)
{
using var activite = Source.StartActivity("ValiderCommande");
activite.SetTag("commande.numero", numero); // warning CS8602
}
}
// Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object.using System.Diagnostics;
var service = new ServiceCommandes();
service.Valider(42, stockSuffisant: true); // personne n'ecoute : aucune activite, aucun cout
// Ce que fait le SDK OpenTelemetry, en plus simple : ecouter une source par son nom.
using var ecouteur = new ActivityListener
{
ShouldListenTo = source => source.Name == "Boutique.Commandes",
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllDataAndRecorded,
ActivityStopped = activite => Console.WriteLine(
$"{activite.DisplayName} span={activite.SpanId} parent={activite.ParentSpanId} " +
$"statut={activite.Status} tags=[{string.Join(", ", activite.TagObjects.Select(t => $"{t.Key}={t.Value}"))}] " +
$"evenements=[{string.Join(", ", activite.Events.Select(e => e.Name))}]"),
};
ActivitySource.AddActivityListener(ecouteur);
service.Valider(43, stockSuffisant: false);
sealed class ServiceCommandes
{
private static readonly ActivitySource Source = new("Boutique.Commandes", "1.0.0");
public void Valider(int numero, bool stockSuffisant)
{
// null quand aucun ecouteur ne veut de la source : d'ou le ?. partout.
using var activite = Source.StartActivity("ValiderCommande");
activite?.SetTag("commande.numero", numero);
Console.WriteLine($"Activite courante : {Activity.Current?.Id ?? "aucune"}");
ReserverStock(stockSuffisant);
if (!stockSuffisant)
{
activite?.AddEvent(new ActivityEvent("stock.insuffisant"));
activite?.SetStatus(ActivityStatusCode.Error, "Stock insuffisant");
}
}
private static void ReserverStock(bool stockSuffisant)
{
// Activity.Current, ValiderCommande, devient le parent implicite.
using var activite = Source.StartActivity("ReserverStock", ActivityKind.Client);
activite?.SetTag("stock.suffisant", stockSuffisant);
}
}
// Les identifiants changent a chaque execution.
// Activite courante : aucune
// Activite courante : 00-1d26738b0d1078ebe05301aa4b2aeb23-35c36586931a0f44-01
// ReserverStock span=27257c786ebb4bfa parent=35c36586931a0f44 statut=Unset tags=[stock.suffisant=False] evenements=[]
// ValiderCommande span=35c36586931a0f44 parent=0000000000000000 statut=Error tags=[commande.numero=43] evenements=[stock.insuffisant] Le premier programme compile avec l'avertissement CS8602, puis s'arrête sur une NullReferenceException : il ne fonctionne que là où quelqu'un écoute, et la panne attend le premier environnement sans écouteur. Le second garde le ?. à chaque appel ; son premier Valider ne crée aucune activité. L'ActivityListener tient ici le rôle du SDK : ShouldListenTo choisit les sources, Sample décide de l'enregistrement. La sortie montre l'arbre : ReserverStock a pour parent le span de ValiderCommande, racine sans parent (seize zéros), qui porte le statut Error et son événement. ActivityKind.Client dit que le span appelle un autre composant ; Server, Producer, Consumer et Internal, le défaut, complètent la liste. Et l'identifiant de l'activité, Activity.Id, a déjà la forme de l'en-tête qui la propage.
Propager le contexte : l'en-tête traceparent
Pour qu'une trace traverse plusieurs services, chaque appel transmet le contexte de l'appelant. Le format est celui de W3C Trace Context, recommandation du 23 novembre 2021 : un en-tête traceparent de quatre champs hexadécimaux en minuscules, séparés par des tirets.
| Champ | Longueur | Rôle |
|---|---|---|
version | 2 | 00 ; ff est invalide |
trace-id | 32 | identifiant de la trace entière ; tout à zéro interdit |
parent-id | 16 | span de l'appelant ; tout à zéro interdit |
trace-flags | 2 | 01 (sampled) : l'appelant a peut-être enregistré la trace |
Un second en-tête, tracestate, transporte les données propres à chaque fournisseur, et baggage des paires clé-valeur applicatives. Le niveau 2 de la spécification, encore brouillon de recommandation candidate (28 mars 2024), ajoute le drapeau 02, random. Depuis .NET 10, DistributedContextPropagator.Current est le propagateur W3C strict ; celui des versions précédentes lisait aussi l'ancien en-tête Request-Id et écrivait le bagage dans Correlation-Context. CreatePreW3CPropagator le rétablit pour dialoguer avec un service qui en dépend encore.
using System.Diagnostics;
using System.Text.Json;
// dotnet run --urls http://localhost:5687
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.Logging.AddFilter("System.Net.Http", LogLevel.Warning);
builder.Services.AddHttpClient("stock", client => client.BaseAddress = new Uri("http://localhost:5687"));
var app = builder.Build();
app.MapGet("/commandes/{numero:int}", async (int numero, IHttpClientFactory fabrique, ILogger<Program> journal) =>
{
// L'activite qu'ASP.NET Core a demarree pour cette requete.
var requete = Activity.Current!;
journal.LogInformation("Commande {Numero} consultee", numero);
var envoye = await fabrique.CreateClient("stock").GetStringAsync("/stock");
return new
{
trace = requete.TraceId.ToString(),
span = requete.SpanId.ToString(),
parent = requete.ParentSpanId.ToString(),
traceparentEnvoye = envoye,
};
});
// Renvoie l'en-tete recu : ce que HttpClient a propage.
app.MapGet("/stock", (HttpRequest requete) => requete.Headers.TraceParent.ToString());
app.Run();curl -s http://localhost:5687/commandes/42 -H "traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
{"trace":"4bf92f3577b34da6a3ce929d0e0e4736","span":"db83e2abaa06de32","parent":"00f067aa0ba902b7","traceparentEnvoye":"00-4bf92f3577b34da6a3ce929d0e0e4736-8e5e71de92c8d986-01"}
# Console de l'API. Les identifiants generes ici (span, parent-id envoye,
# ConnectionId, RequestId) changent a chaque execution.
{
"EventId": 0,
"LogLevel": "Information",
"Category": "Program",
"Message": "Commande 42 consultee",
"State": {
"Numero": 42,
"{OriginalFormat}": "Commande {Numero} consultee"
},
"Scopes": [
{
"Message": "SpanId:db83e2abaa06de32, TraceId:4bf92f3577b34da6a3ce929d0e0e4736, ParentId:00f067aa0ba902b7",
"SpanId": "db83e2abaa06de32",
"TraceId": "4bf92f3577b34da6a3ce929d0e0e4736",
"ParentId": "00f067aa0ba902b7"
},
{
"Message": "ConnectionId:0HNORK9R1VCCR",
"ConnectionId": "0HNORK9R1VCCR"
},
{
"Message": "RequestPath:/commandes/42 RequestId:0HNORK9R1VCCR:00000001",
"RequestId": "0HNORK9R1VCCR:00000001",
"RequestPath": "/commandes/42"
}
]
} Sans aucun paquet, la trace traverse ici l'API. ASP.NET Core lit traceparent : l'activité de la requête reprend le trace-id et prend pour parent le parent-id reçu. HttpClient crée sa propre activité pour l'appel sortant et envoie son identifiant à elle : le parent-id que reçoit /stock est celui de l'appel, pas celui de la requête. Deux conditions tiennent ce résultat quand rien n'écoute. ASP.NET Core ne crée l'activité de requête que si un journal est actif : un ClearProviders() sans autre fournisseur, et plus rien ne part. HttpClient ne crée la sienne que s'il existe une activité courante : un appel lancé hors de toute requête, par un service d'arrière-plan, n'envoie aucun traceparent. La documentation de .NET, au 26 septembre 2026, dit seulement que ses activités naissent quand un écouteur s'abonne ; le code de DiagnosticsHandler en .NET 10 fait ce qu'on observe. Sans échantillonneur, chaque activité hérite du drapeau de son parent, 01 ou 00 ; une racine, comme la requête arrivée sans en-tête, vaut 00.
Les journaux portent la trace
La ligne de journal porte le même TraceId dans sa première portée : la fabrique de journaux l'ajoute d'après l'activité courante, comme le détaille la section « Les portées de journalisation » du cours « Configuration et journalisation ». Un outil qui reçoit journaux et traces passe alors d'une ligne d'erreur à la trace complète de sa requête, et d'un span lent à ses journaux. Le fournisseur de journaux d'OpenTelemetry, qu'active WithLogging, va plus loin : il remplit les champs TraceId, SpanId et TraceFlags de chaque enregistrement depuis l'activité courante, sans dépendre des portées ni d'IncludeScopes.
Mesurer : Meter et ses instruments
Une métrique ne garde pas chaque mesure : l'outil qui l'écoute les agrège sur un intervalle, une somme pour un compteur, une répartition par tranches pour un histogramme, et n'exporte que le résultat. Son coût ne dépend donc pas du trafic. Un Meter s'obtient par IMeterFactory, que tout hôte .NET 8+ enregistre, plutôt qu'en champ statique : chaque conteneur de services, donc chaque test, a les siens. Le type d'instrument dit comment agréger.
| Instrument | Mesure | Exemple |
|---|---|---|
Counter<T> | ce qui ne fait que croître ; on en lit le débit | commandes validées |
UpDownCounter<T> | ce qui monte et descend | paniers ouverts, requêtes en cours |
Histogram<T> | une distribution ; on en lit les centiles | durée d'un paiement |
Gauge<T> (.NET 9+) | une valeur courante, écrite quand elle change | taille d'une file |
ObservableCounter, ObservableUpDownCounter, ObservableGauge | les mêmes, lus par un rappel au moment de la collecte | niveau du stock |
// Projet web ou worker (usings implicites de l'hote) ; une console ordinaire
// ajoute le paquet Microsoft.Extensions.Hosting.
using System.Diagnostics.Metrics;
using System.Globalization;
var builder = Host.CreateApplicationBuilder(args); // l'hote enregistre IMeterFactory
builder.Services.AddSingleton<Stock>();
builder.Services.AddSingleton<MetriquesBoutique>();
using var hote = builder.Build();
var metriques = hote.Services.GetRequiredService<MetriquesBoutique>();
// Ce que fait le SDK OpenTelemetry, en plus simple : s'abonner aux instruments d'un Meter.
using var ecouteur = new MeterListener
{
InstrumentPublished = (instrument, abonnement) =>
{
if (instrument.Meter.Name == "Boutique.Commandes") abonnement.EnableMeasurementEvents(instrument);
},
};
ecouteur.SetMeasurementEventCallback<long>(Afficher);
ecouteur.SetMeasurementEventCallback<int>(Afficher);
ecouteur.SetMeasurementEventCallback<double>(Afficher);
ecouteur.Start();
metriques.PanierOuvert();
metriques.CommandeValidee("web", dureePaiement: 0.412);
metriques.PanierFerme();
ecouteur.RecordObservableInstruments(); // un observable n'est lu qu'a la collecte
static void Afficher<T>(Instrument instrument, T valeur,
ReadOnlySpan<KeyValuePair<string, object?>> etiquettes, object? etat)
{
var texte = string.Join(", ", etiquettes.ToArray().Select(e => $"{e.Key}={e.Value}"));
Console.WriteLine(string.Create(CultureInfo.InvariantCulture,
$"{instrument.GetType().Name.Split('`')[0],-15} {instrument.Name} {valeur} {instrument.Unit} [{texte}]"));
}
sealed class Stock
{
public Dictionary<string, int> Niveaux { get; } = new() { ["CAFE-250"] = 12, ["THE-100"] = 3 };
}
sealed class MetriquesBoutique
{
private readonly Counter<long> _commandes;
private readonly Histogram<double> _dureePaiement;
private readonly UpDownCounter<int> _paniersOuverts;
public MetriquesBoutique(IMeterFactory fabrique, Stock stock)
{
var meter = fabrique.Create("Boutique.Commandes");
_commandes = meter.CreateCounter<long>("boutique.commandes", "{commande}");
_dureePaiement = meter.CreateHistogram<double>("boutique.paiement.duree", "s");
_paniersOuverts = meter.CreateUpDownCounter<int>("boutique.paniers.ouverts", "{panier}");
// Observable : personne ne pousse la valeur, le collecteur la demande.
meter.CreateObservableGauge("boutique.stock.niveau", () => stock.Niveaux.Select(article =>
new Measurement<int>(article.Value, new KeyValuePair<string, object?>("article.reference", article.Key))),
"{article}");
}
public void PanierOuvert() => _paniersOuverts.Add(1);
public void PanierFerme() => _paniersOuverts.Add(-1);
public void CommandeValidee(string canal, double dureePaiement)
{
var etiquette = new KeyValuePair<string, object?>("commande.canal", canal);
_commandes.Add(1, etiquette);
_dureePaiement.Record(dureePaiement, etiquette);
}
}
// UpDownCounter boutique.paniers.ouverts 1 {panier} []
// Counter boutique.commandes 1 {commande} [commande.canal=web]
// Histogram boutique.paiement.duree 0.412 s [commande.canal=web]
// UpDownCounter boutique.paniers.ouverts -1 {panier} []
// ObservableGauge boutique.stock.niveau 12 {article} [article.reference=CAFE-250]
// ObservableGauge boutique.stock.niveau 3 {article} [article.reference=THE-100] Les noms suivent les conventions d'OpenTelemetry : minuscules, points hiérarchiques, et l'unité à part, en UCUM : s pour les secondes, By pour les octets, {commande} pour un nombre de choses. Une durée s'enregistre en secondes, dans un double, comme le fait ASP.NET Core. La jauge observable n'apparaît qu'au RecordObservableInstruments : son rappel ne tourne qu'à la collecte, ce qui convient à une valeur déjà tenue ailleurs, un stock, une taille de file, et impose un rappel rapide.
La cardinalité des étiquettes
Chaque combinaison distincte de valeurs d'étiquettes forme une série, que le SDK garde en mémoire et que le backend stocke et indexe. Une étiquette aux valeurs non bornées, un identifiant de client, une URL qui contient un numéro de commande, multiplie les séries par le nombre de clients :
using System.Diagnostics.Metrics;
sealed class MetriquesCommandes(IMeterFactory fabrique)
{
private readonly Counter<long> _commandes = fabrique
.Create("Boutique.Commandes")
.CreateCounter<long>("boutique.commandes", "{commande}");
// Une serie par client : 10 000 clients, 10 000 series a garder et a exporter.
public void CommandeValidee(string clientId, string canal) =>
_commandes.Add(1, new("client.id", clientId), new("commande.canal", canal));
}using System.Diagnostics.Metrics;
sealed class MetriquesCommandes(IMeterFactory fabrique)
{
private readonly Counter<long> _commandes = fabrique
.Create("Boutique.Commandes")
.CreateCounter<long>("boutique.commandes", "{commande}");
// Des valeurs bornees : 3 canaux x 2 = 6 series, quel que soit le nombre de clients.
public void CommandeValidee(string canal, decimal montant) =>
_commandes.Add(1, new("commande.canal", canal), new("commande.gros_panier", montant >= 200m));
} Dix mille commandes de dix mille clients produisent 10 000 séries avec la première classe, 6 avec la seconde. Le coût n'est pas seul en cause : le SDK OpenTelemetry .NET plafonne chaque métrique à 2 000 combinaisons par défaut, et depuis sa version 1.10 il agrège le surplus dans une seule série marquée otel.metric.overflow = true. Passé ce seuil, les chiffres par client deviennent incomplets. « Que s'est-il passé pour ce client ? » est une question de traces et de journaux, où l'identifiant a sa place ; une métrique n'étiquette que par des valeurs bornées et connues d'avance : un canal, un statut, une route. Le plafond d'une métrique précise se règle par une vue, MetricStreamConfiguration.CardinalityLimit.
OpenTelemetry .NET : SDK, instrumentations, OTLP
Le SDK remplace l'ActivityListener et le MeterListener des exemples : il écoute les sources et les meters qu'on lui nomme, échantillonne, agrège, puis exporte. Quatre paquets suffisent à une API : le SDK et son intégration à l'hôte, les instrumentations d'ASP.NET Core et de HttpClient, l'exportateur OTLP.
// Paquets (versions courantes au 26 septembre 2026) :
// OpenTelemetry.Extensions.Hosting 1.19.1
// OpenTelemetry.Exporter.OpenTelemetryProtocol 1.19.1
// OpenTelemetry.Instrumentation.AspNetCore 1.19.0
// OpenTelemetry.Instrumentation.Http 1.19.0
using OpenTelemetry;
using OpenTelemetry.Metrics;
using OpenTelemetry.Trace;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<ServiceCommandes>(); // celui de la premiere section
builder.Services.AddHealthChecks();
// Forme de la 1.19 : service.name et deployment.environment.name viennent de l'hote.
builder.AddOpenTelemetry()
.WithTracing(traces => traces
.AddSource("Boutique.*")
.AddAspNetCoreInstrumentation(options =>
// Les sondes de l'orchestrateur, plusieurs fois par minute : pas des traces utiles.
options.Filter = contexte => !contexte.Request.Path.StartsWithSegments("/sante"))
.AddHttpClientInstrumentation())
.WithMetrics(metriques => metriques
.AddMeter("Boutique.*")
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddMeter("System.Runtime"))
.WithLogging()
.UseOtlpExporter(); // les trois signaux en OTLP, regles par les variables OTEL_*
var app = builder.Build();
app.MapHealthChecks("/sante/vivant");
app.MapPost("/commandes/{numero:int}/validation", (int numero, ServiceCommandes commandes) =>
{
commandes.Valider(numero);
return Results.NoContent();
});
app.Run(); Rien n'est écouté par défaut. AddSource et AddMeter acceptent un joker, Boutique.*, qui couvre les noms commençant par Boutique. : oublier une source, c'est retrouver en production le null de la deuxième section. L'instrumentation ASP.NET Core écoute l'activité de chaque requête et lui ajoute ses attributs, http.route, url.path, http.response.status_code ; côté métriques, depuis .NET 8, elle ne fait qu'ajouter les meters intégrés d'ASP.NET Core. Son Filter écarte les appels des sondes de santé que décrit la section « Health checks : vivant ou prêt » du cours « Résilience et performance ». Depuis .NET 9, l'activité de HttpClient porte d'elle-même ses attributs, et AddSource("System.Net.Http") pourrait remplacer son instrumentation, qui reste utile pour enrichir. UseOtlpExporter branche un exportateur OTLP sur les trois signaux ; il ne s'appelle qu'une fois et ne se combine pas avec les AddOtlpExporter propres à chaque signal. builder.AddOpenTelemetry(), apparu avec la 1.19.0, déduit en plus service.name du nom de l'application et deployment.environment.name de l'environnement ; avant, on écrivait builder.Services.AddOpenTelemetry(), et, sans OTEL_SERVICE_NAME, le service s'appelait unknown_service: suivi du nom du processus.
Le reste se règle au déploiement, par des variables d'environnement que définit la spécification d'OpenTelemetry pour tous les langages ; les défauts, eux, varient d'un SDK à l'autre, et le tableau donne ceux du SDK .NET. Le SDK .NET lit ses clés par IConfiguration : celles de l'exportateur OTLP peuvent donc venir aussi d'appsettings.json.
export OTEL_SERVICE_NAME=boutique-api
export OTEL_RESOURCE_ATTRIBUTES=service.version=1.4.2,deployment.environment.name=production
export OTEL_EXPORTER_OTLP_ENDPOINT=http://collecteur:4317
export OTEL_EXPORTER_OTLP_PROTOCOL=grpc
export OTEL_TRACES_SAMPLER=parentbased_traceidratio
export OTEL_TRACES_SAMPLER_ARG=0.1
dotnet Boutique.Api.dll| Variable | Rôle | Défaut |
|---|---|---|
OTEL_SERVICE_NAME | nom du service, sur tous les signaux | nom de l'application avec builder.AddOpenTelemetry(), sinon unknown_service: et le nom du processus |
OTEL_RESOURCE_ATTRIBUTES | attributs de ressource : version, environnement | telemetry.sdk.* du SDK ; avec builder.AddOpenTelemetry(), deployment.environment.name en plus |
OTEL_EXPORTER_OTLP_ENDPOINT | adresse du collecteur | localhost:4317 en gRPC, localhost:4318 en HTTP, comme dans la spécification |
OTEL_EXPORTER_OTLP_PROTOCOL | grpc ou http/protobuf | grpc en .NET, conservé par compatibilité ; la spécification (v1.61.0) recommande http/protobuf |
OTEL_TRACES_SAMPLER, _ARG | stratégie d'échantillonnage et son paramètre | parentbased_always_on |
parentbased_traceidratio à 0,1 garde une trace nouvelle sur dix, tirée d'après son trace-id, et suit la décision de l'appelant quand un traceparent arrive : c'est le drapeau 01 ou 00 qui la transporte, et chaque trace est gardée ou perdue d'un bloc par tous les services qui suivent leur parent. Les métriques ne s'échantillonnent pas : elles comptent tout.
Où vont les données : collecteur et backends
L'application peut exporter directement vers un backend, mais le collecteur OpenTelemetry s'intercale le plus souvent : un programme séparé qui reçoit l'OTLP, le traite et le réexpédie. L'application ne connaît qu'une adresse ; changer de backend, en ajouter un second, retirer un attribut sensible ou borner la mémoire devient une affaire de configuration du collecteur, sans redéployer. Il tourne en agent, à côté de l'application : dans Kubernetes, un conteneur de plus dans le pod, qu'elle joint par localhost comme le décrit le cours « Kubernetes » (un sidecar), ou un exemplaire par nœud (un DaemonSet). Il tourne aussi en passerelle centrale, devant les backends, et l'application le joint alors par son Service.
# config.yaml du collecteur OpenTelemetry (otelcol v0.161.0, 14 septembre 2026)
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317 # defaut localhost : injoignable hors du pod (DaemonSet, passerelle)
http:
endpoint: 0.0.0.0:4318 # 0.0.0.0 ecoute sur toutes les interfaces
processors:
memory_limiter:
check_interval: 1s
limit_mib: 400
spike_limit_mib: 100
exporters:
otlp_grpc: # nomme otlp avant la v0.144.0 ; l'ancien nom reste un alias deprecie
endpoint: backend.exemple.interne:4317 # TLS exige par defaut
sending_queue:
batch: # regroupe les envois ; vide = reglages par defaut
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter]
exporters: [otlp_grpc]
metrics:
receivers: [otlp]
processors: [memory_limiter]
exporters: [otlp_grpc]
logs:
receivers: [otlp]
processors: [memory_limiter]
exporters: [otlp_grpc] Une configuration déclare des récepteurs, des processeurs et des exportateurs, puis les assemble en pipelines, un par signal ; un composant déclaré mais absent de tout pipeline n'est pas activé. memory_limiter se place en tête des processeurs : au-delà de sa limite, il refuse les données et renvoie l'erreur vers le récepteur, qui ralentit l'envoi, au lieu de laisser le collecteur mourir faute de mémoire. sending_queue.batch regroupe les envois de l'exportateur.
Côté backend, le choix dépend de l'équipe et du budget : les traces vont dans un magasin comme Jaeger ou Grafana Tempo, les métriques dans une base de séries comme Prometheus, les journaux dans Loki, Elasticsearch ou OpenSearch, et des offres complètes, Azure Monitor ou les fournisseurs spécialisés, prennent les trois. Le tableau de bord d'Aspire, qui reçoit l'OTLP, suffit sur un poste de développement. Les critères qui départagent : la réception native de l'OTLP, le passage d'un signal à l'autre par le trace-id, la durée de rétention, et le prix au volume, que la cardinalité fait exploser.
Ce que .NET 10 émet de lui-même
Avant la première ligne d'instrumentation, le runtime et ASP.NET Core publient déjà des métriques, sous des noms tirés des conventions sémantiques d'OpenTelemetry : les tableaux de bord prévus pour ces conventions les lisent tels quels.
using System.Diagnostics.Metrics;
// dotnet run --urls http://localhost:5813
// Liste les instruments que .NET 10 et ASP.NET Core 10 publient d'eux-memes.
var instruments = new SortedSet<string>(StringComparer.Ordinal);
using var ecouteur = new MeterListener
{
InstrumentPublished = (instrument, abonnement) =>
{
var meter = instrument.Meter.Name;
if (meter.StartsWith("Microsoft.AspNetCore") || meter.StartsWith("System."))
instruments.Add($"{meter,-36} {instrument.Name}");
},
};
ecouteur.Start();
var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();
var app = builder.Build();
app.MapGet("/", () => "ok");
await app.StartAsync();
// Une requete, pour que Kestrel et HttpClient creent leurs instruments.
using var client = new HttpClient();
await client.GetStringAsync(app.Urls.First());
await app.StopAsync();
foreach (var ligne in instruments) Console.WriteLine(ligne);
// En Development (profil du template) ; en Production, sans la page d'exception
// du developpeur, la premiere ligne disparait.
// Microsoft.AspNetCore.Diagnostics aspnetcore.diagnostics.exceptions
// Microsoft.AspNetCore.Hosting http.server.active_requests
// Microsoft.AspNetCore.Hosting http.server.request.duration
// Microsoft.AspNetCore.MemoryPool aspnetcore.memory_pool.allocated
// Microsoft.AspNetCore.MemoryPool aspnetcore.memory_pool.evicted
// Microsoft.AspNetCore.MemoryPool aspnetcore.memory_pool.pooled
// Microsoft.AspNetCore.MemoryPool aspnetcore.memory_pool.rented
// Microsoft.AspNetCore.Routing aspnetcore.routing.match_attempts
// Microsoft.AspNetCore.Server.Kestrel kestrel.active_connections
// Microsoft.AspNetCore.Server.Kestrel kestrel.active_tls_handshakes
// Microsoft.AspNetCore.Server.Kestrel kestrel.connection.duration
// Microsoft.AspNetCore.Server.Kestrel kestrel.queued_connections
// Microsoft.AspNetCore.Server.Kestrel kestrel.queued_requests
// Microsoft.AspNetCore.Server.Kestrel kestrel.rejected_connections
// Microsoft.AspNetCore.Server.Kestrel kestrel.tls_handshake.duration
// Microsoft.AspNetCore.Server.Kestrel kestrel.upgraded_connections
// System.Net.Http http.client.active_requests
// System.Net.Http http.client.connection.duration
// System.Net.Http http.client.open_connections
// System.Net.Http http.client.request.duration
// System.Net.Http http.client.request.time_in_queue
// System.Net.NameResolution dns.lookup.duration
// System.Runtime dotnet.assembly.count
// System.Runtime dotnet.exceptions
// System.Runtime dotnet.gc.collections
// System.Runtime dotnet.gc.heap.total_allocated
// System.Runtime dotnet.gc.last_collection.heap.fragmentation.size
// System.Runtime dotnet.gc.last_collection.heap.size
// System.Runtime dotnet.gc.last_collection.memory.committed_size
// System.Runtime dotnet.gc.pause.time
// System.Runtime dotnet.jit.compilation.time
// System.Runtime dotnet.jit.compiled_il.size
// System.Runtime dotnet.jit.compiled_methods
// System.Runtime dotnet.monitor.lock_contentions
// System.Runtime dotnet.process.cpu.count
// System.Runtime dotnet.process.cpu.time
// System.Runtime dotnet.process.memory.working_set
// System.Runtime dotnet.thread_pool.queue.length
// System.Runtime dotnet.thread_pool.thread.count
// System.Runtime dotnet.thread_pool.work_item.count
// System.Runtime dotnet.timer.counthttp.server.request.duration, un histogramme en secondes étiqueté par http.route, méthode et statut, donne à lui seul la latence par route et le taux d'erreurs ; http.server.active_requests compte les requêtes en cours, http.client.request.duration mesure les appels sortants. Le meter System.Runtime, apparu avec .NET 9, couvre le ramasse-miettes, le JIT, le pool de threads, les contentions de verrou et les exceptions : dotnet.thread_pool.queue.length qui grimpe trahit un pool de threads à court, dotnet.gc.pause.time le temps perdu en collectes. Il prend la relève des EventCounters, et sur .NET 9 et au-delà le paquet OpenTelemetry.Instrumentation.Runtime ne fait plus qu'ajouter ce meter, d'où l'AddMeter("System.Runtime") de l'exemple OpenTelemetry. D'autres meters apparaissent avec leur fonction : limitation de débit, authentification, autorisation ; Microsoft.AspNetCore.MemoryPool est nouveau en .NET 10. Côté traces, les sources Microsoft.AspNetCore et System.Net.Http émettent l'activité de chaque requête entrante et sortante, et depuis .NET 9 des sources expérimentales, Experimental.System.Net.Http.Connections et Experimental.System.Net.Sockets, détaillent l'établissement des connexions. Une métrique n'est pas un health check : elle raconte une tendance à qui la lit, quand le health check répond oui ou non à un orchestrateur ; les deux se complètent.