Aspire 3 : Observabilite .NET moderne — Serilog, OpenTelemetry, ActivitySource
Ce notebook digere la Part 5 de la serie The Unexpected AI Stack: C# + .NET (voir issue #11516) : la pile d’observabilite que l’auteur branche autour de son agent — Serilog pour les logs structures, OpenTelemetry pour les traces, et un point souvent neglige, l’instrumentation call-site via ActivitySource et les attributs Caller*.
L’observabilite, c’est repondre a trois questions sur un systeme en production : que s’est-il passe ? (logs), ou exactement ? (traces) et combien ? (metrics). Les trois se rejoignent dans le standard OpenTelemetry (OTel), devenu le lingua franca des fournisseurs de telemetry — instrumenter une fois, exporter vers Jaeger, Grafana, Azure Monitor ou la console, sans changer le code metier.
Ce que ce notebook demontre, cellule par cellule :
le log structure Serilog — le template {Propriete} produit des champs, pas des strings interpolables ;
le span OpenTelemetry — un ActivitySource alimente un TracerProvider muni d’un exporteur console ;
l’instrumentation call-site — CallerMemberName / CallerFilePath / CallerLineNumber remplissent les tags code.function / code.file.path / code.line.numberautomatiquement, sans argument passe a la main ;
la correlation — les evenements Serilog et les spans OTel partagent les memes identifiants (@tr = TraceId, @sp = SpanId) : un log se rattache a sa trace sans aucune glue de notre cru.
Position dans la serie : Aspire 1 (orchestration GenAI) et Aspire 2 (stack reel) couvrent l’AppHost ; le dossier aspire-otel/ du repo contient un AppHost OTLP de reference. Ce notebook se place en amont de l’outil : il execute la librairie (.NET Interactive en local), pas l’orchestrateur.
1. Serilog : le log structure
Le defaut de Console.WriteLine et du ILogger basique : tout devient du texte. Chercher « toutes les requetes de l’etudiant-42 » dans un log texte = grep fragile sur des formats dates. Le log structure inverse le rapport : le message est un template nomme, et chaque trou {Utilisateur} devient un champ typé dans la sortie.
Avec le formateur compact JSON de Serilog, chaque evenement devient un objet — @t (horodatage), @mt (template), @tr/@sp (trace/span, on y revient en section 4), puis les champs nommes du template.
// Le logger Serilog : template nomme, sink console, formateur JSON compact.var log =newLoggerConfiguration().Enrich.WithProperty("app","aspire03-demo").WriteTo.Console(new Serilog.Formatting.Compact.CompactJsonFormatter()).CreateLogger();// Template structure : {Utilisateur} et {Question} deviennent des CHAMPS.log.Information("requete recue {Utilisateur} question={Question}","etudiant-42","qu'est-ce qu'un span ?");// A comparer : l'interpolation C# classique produit une ligne de texte plate.Console.WriteLine($"requete recue utilisateur=etudiant-42 (interpolation, pas de champs)");
{"@t":"2026-10-04T01:32:04.5896423Z","@mt":"requete recue {Utilisateur} question={Question}","@tr":"e92d3cde3a5ab6e231234ba718a58fdc","@sp":"37db21d46e624af1","Utilisateur":"etudiant-42","Question":"qu'est-ce qu'un span ?","app":"aspire03-demo"}
requete recue utilisateur=etudiant-42 (interpolation, pas de champs)
Lecture du resultat. Deux lignes pour la meme information : la ligne Serilog est un objet JSON (@t, @mt, puis Utilisateur et Question comme champs distincts) ; la ligne interpolee est du texte. Dans un agrégateur (Elastic, Loki, Application Insights), la premiere se filtre par Utilisateur = "etudiant-42" en index ; la seconde exige un grep. C’est tout l’enjeu du log structure : la machine indexe, l’humain relit.
Le prefixe app=aspire03-demo vient de Enrich.WithProperty — un champ pose une fois pour toutes les lignes du logger (environnement, version, service).
1bis. L’origine de l’appel : l’enrichissement call-site {Here}
Enrich.WithProperty pose un champ fixe. L’étape suivante du log structuré est de capturer d’où la ligne a été émise — méthode, fichier, numéro de ligne — sans le passer à la main à chaque appel. C# le fait à la compilation avec les caller-info attributes ([CallerMemberName], [CallerFilePath], [CallerLineNumber]) : des paramètres optionnels laissés vides à l’appel, remplis par le compilateur avec le site exact de l’invocation. La fonction Here() ci-dessous les récupère et les attache par ForContext ; deux voies de rendu suivent : le champ Here comme champ structuré du JSON compact (indexable comme Utilisateur), et le placeholder ({Here}) dans un outputTemplate de console. Source : The Unexpected AI Stack Part 5 (chrlschn.dev), qui la justifie pour les agents — un diagnostic lu par un agent obtient la ligne de vue directe vers l’origine du problème dans la source.
Deux constats honnêtes sur la sortie : MemberName et LineNumber sont capturés (:TraiterRequete@23 — le numéro est celui de la ligne dans la cellule), mais FilePath est vide : une cellule de kernel n’a pas de fichier source .cs sous-jacent ; en programme complet, le rendu serait Health.cs:HandleAsync@26 comme dans l’article. C’est aussi le pont vers la section 3 : les tags call-site d’Activity y démontrent le même principe côté traces.
// L'enrichissement {Here} : caller-info attributes + ForContext (Part 5 chrlschn.dev).// Contrainte kernel : une methode d'extension exige une classe statique de niveau// top, qu'une cellule ne peut pas declarer (CS1109) -- en programme complet, Here()// vivrait dans un LoggerExtensions.cs comme `public static ILogger Here(this ILogger ...)`.// Ici : fonction locale ordinaire, meme signature moins le `this`.using System.Runtime.CompilerServices;ILogger Here(ILogger logger,[CallerMemberName]string memberName ="",[CallerFilePath]string sourceFilePath ="",[CallerLineNumber]int sourceLineNumber =0){var srcFile = Path.GetFileName(sourceFilePath);return logger.ForContext("Here", $"{srcFile}:{memberName}@{sourceLineNumber}").ForContext("MemberName", memberName).ForContext("FilePath", sourceFilePath).ForContext("LineNumber", sourceLineNumber);}// Voie 1 : le logger JSON compact de la cellule precedente -- {Here} devient un CHAMP.voidTraiterRequete(string utilisateur)=>Here(log).Information("requete traitee {Utilisateur}", utilisateur);TraiterRequete("etudiant-42");// Voie 2 : le rendu template -- {Here} dans l'outputTemplate, comme dans l'article.var logTemplate =newLoggerConfiguration().Enrich.WithProperty("app","aspire03-demo").WriteTo.Console(outputTemplate:"[{Timestamp:HH:mm:ss.fff} {Level:u3}] {Message:lj} ({Here}){NewLine}{Exception}").CreateLogger();voidVerifierSante()=>Here(logTemplate).Information("verification sante demandee");VerifierSante();
Cote traces, l’unite est le span : une duree nommee, avec debut, fin, tags et relation parent/enfant. En .NET, un span s’appelle un Activity (l’API existe dans le BCL), et c’est ActivitySource qui les emet — un producteur nomme que le SDK OTel branche ensuite vers des exporteurs.
La plomberie complete tient en quelques lignes : creer la source, construire un TracerProvider qui l’ecoute, y ajouter un exporteur. Ici l’exporteur est la console ; en production ce serait OTLP vers un collector.
Deux lignes du code ci-dessous sont dictees par le contexte kernel et commentees comme telles : le sampler explicite et le processeur d’export immediat. Dans un programme console ordinaire, AddConsoleExporter() seul suffirait.
// L'ActivitySource : le producteur de spans, nomme comme le service.// (static dans un programme ; ici variable simple car cellule notebook.)var source =newActivitySource("Aspire03.Demo");// Le TracerProvider OTel : ecoute la source, exporte sur la console.// Deux precautions dictees par le contexte kernel (verifiees empiriquement) :// - SetSampler(new AlwaysOnSampler()) : le kernel .NET Interactive maintient// une Activity parent ambiante, et le sampler par defaut (ParentBased)// refuserait tout span local -> StartActivity retournerait null ;// - SimpleActivityExportProcessor : exporte chaque span a sa fermeture ; le// processeur batch par defaut ne flush qu'a la destruction du provider,// qui n'arrive jamais dans un kernel vivant.var tracer = Sdk.CreateTracerProviderBuilder().AddSource("Aspire03.Demo").SetSampler(newAlwaysOnSampler()).AddProcessor(newSimpleActivityExportProcessor(newConsoleActivityExporter(newConsoleExporterOptions()))).Build();using(var racine = source.StartActivity("TraitementAgent")){ racine?.SetTag("run.id", Environment.TickCount64);using(var enfant = source.StartActivity("AppelModele")){ enfant?.SetTag("model","factice-local"); Thread.Sleep(50);// simule l'appel}}Console.WriteLine("spans exportes ci-dessus");
Lecture des spans. L’exporteur console dechiffre chaque Activity : identifiants (TraceId, SpanId, ParentSpanId — l’enfant pointe vers le parent), DisplayName (le nom passe a StartActivity), Kind, StartTime, Duration, puis les Tags poses a la main (run.id, model) et la Resource — les attributs du process exporteur (telemetry.sdk.*, service.name).
L’ordre d’affichage surprend au premier regard : l’enfant AppelModele apparait avant le parent TraitementAgent. Un span n’est exporte qu’a sa fermeture, et l’enfant se ferme le premier — l’ordre d’export est l’ordre des fermetures, pas celui des ouvertures. La hierarchie, elle, se lit dans les ParentSpanId.
Deux details qui comptent : le TraitementAgent racine porte lui-meme un ParentSpanId — c’est l’activite ambiante du kernel .NET Interactive qui execute la cellule (raison pour laquelle le sampler explicite ci-dessus est necessaire) ; et service.name vaut unknown_service:dotnet-interactive faute de variable d’environnement OTEL_SERVICE_NAME — la poser est la premiere configuration d’un deploiement reel, ou elle identifierait le service dans le backend de traces.
3. L’instrumentation call-site : les tags qui ne mentent jamais
Un piege classique de l’instrumentation manuelle : ecrire le nom de la fonction a la main dans le tag — SetTag("code.function", "Traiter") — puis renommer la fonction et laisser le tag mentir. Les attributs de compilation [CallerMemberName], [CallerFilePath], [CallerLineNumber] ferment ce piege : le compilateur injecte la valeur reelle au site d’appel, gratuitement.
Le helper ci-dessous est le pattern de la Part 5 : un demarrage de span qui porte son propre code de localisation. Les tags produits suivent la convention OTel semantique (code.function, code.file.path, code.line.number).
#nullable enable// Helper call-site : le compilateur remplit member/file/line au site d'appel.Activity?SpanCallSite([System.Runtime.CompilerServices.CallerMemberName]string member ="",[System.Runtime.CompilerServices.CallerFilePath]string file ="",[System.Runtime.CompilerServices.CallerLineNumber]int line =0){var a = source.StartActivity(member); a?.SetTag("code.function", member); a?.SetTag("code.file.path", Path.GetFileName(file)); a?.SetTag("code.line.number", line);return a;}// La preuve : les tags portent le VRAI nom, fichier et numero de ligne.using(var s =SpanCallSite()){ Console.WriteLine("span ouvert, tags poses par le compilateur");}
Lecture du span affiche. Regardez les trois tags : code.function porte <Initialize> et code.line.number vaut 16 — la ligne de l’appel. Aucune de ces valeurs n’a ete ecrite a la main : le compilateur C# les injecte au site d’appel via les attributs Caller*.
Deux nuances propres au notebook, lisibles dans les tags : <Initialize> est le nom que le kernel donne au bloc racine d’une cellule (chaque cellule est compilee comme une submission Roslyn) — c’est precisement la preuve que la valeur vient du compilateur et non d’une saisie ; et code.file.path est vide parce qu’une cellule n’a pas de fichier source sur disque. Dans un programme reel, le membre serait une methode nommee et le chemin son fichier source — la section 4 le montre : le meme helper, appele depuis la methode Traiter, porte code.function: Traiter.
4. La correlation : un log dans sa trace
Le morceau de glue que la pile moderne offre gratuitement : quand un evenement Serilog est emis pendant qu’un span est actif, l’enrichisseur de contexte pose automatiquement @tr (TraceId) et @sp (SpanId) dans le JSON — les memes identifiants que l’exporteur de traces. Resultat : cliquer sur un log dans un dashboard et retomber sur le span exact qui l’entourait.
La demo : un mini-agent factice dont chaque etape ouvre un span (via le helper call-site) ET emet un log Serilog structure.
// Mini-agent factice : chaque etape = un span call-site + un log Serilog.stringTraiter(string utilisateur,string question){usingvar span =SpanCallSite(); log.Information("requete recue {Utilisateur} question={Question}", utilisateur, question);var duree = question.Length*7; Thread.Sleep(30); log.Information("reponse emise duree_ms={DureeMs}", duree); span?.SetTag("reponse.duree_ms", duree);return $"reponse en {duree} ms";}using(var racine = source.StartActivity("Run")){Traiter("etudiant-42","qu'est-ce qu'un span ?");Traiter("etudiant-07","pourquoi CallerMemberName ?");}
Lecture de la correlation — la demonstration cles de ce notebook. Relevez le @tr des evenements Serilog et le TraceId des spans exportes : meme valeur. Le @sp de « requete recue » egale le SpanId du span Traiter (emis pendant qu’il est actif). Aucune ligne de code ne relie les deux fleuves : Serilog lit l’Activity.Current du thread, OTel exporte le meme. C’est ce qui permet, dans un backend type Jaeger ou Application Insights, de partir d’une erreur dans les logs et de reconstituer toute la chronologie du traitement.
En production, la plomberie ne change pas : seul l’exporteur passe de la console a OTLP (AddOtlpExporter), et le dashboard Aspire (port 15050 par defaut) ou aspire otel spans consomme le meme flux.
5. La boucle fermee : OTLP + dashboard Aspire
Les sections 1 a 4 operent en local : l’exporteur est la console, et le seul verdict que vous avez vu sur les sorties precedentes vient de l’oeil qui lit du JSON. En production, on remplace l’exporteur console par un exporteur OTLP (OpenTelemetry Line Protocol), on envoie les spans sur le reseau, et un dashboard Aspire (port 17149 par defaut avec --isolated, sinon 15050) les recoit et les restitue en interface graphique. La derniere marche est celle qui rend l’observabilite utilisable : on ne relit plus du JSON, on filtre dans une UI.
Cette section prepare la boucle fermee sous forme d’EXERCICES a completer par l’etudiant :
un TracerProvider configure avec AddOtlpExporter (HTTP/protobuf, le format qu’Aspire 13 expose par defaut) vers http://localhost:4317 ;
un pont Serilog -> OpenTelemetry : les evenements Serilog sont emis comme ILogger OTel et traces sur le meme flux OTLP ;
un exercice 4 (stub C.1) qui demande d’instrumenter une BackgroundService reelle et de verifier dans le dashboard Aspire les spans produits via aspire otel spans app-backend --search ....
L’orchestrateur requis est SkOtel.AppHost (dossier aspire-otel/SkOtel.AppHost du depot) : un AppHost minimal Aspire 13 qui expose uniquement le dashboard OTLP. Pour le demarrer en local : aspire start --non-interactive depuis ce dossier ; le dashboard apparait sur l’URL retournee par la CLI.
Important : AddOtlpExporter est configure ici en HTTP/protobuf (OtlpExportProtocol.HttpProtobuf) – le dashboard Aspire 13 n’expose pas l’endpoint gRPC OTLP par defaut. La sortie de la cellule 15 documente le format reel et l’absence de timeout dans Dispose() (un exporter OTLP peut bloquer si le collector est absent – precaution kernel : ForceFlush(2000) avant Dispose(), sinon la cellule reste pendue jusqu’au timeout MCP).
// Exercice 5 : OTLP exporter vers Aspire dashboard//// Objectif : envoyer un span via OTLP HTTP/protobuf vers le dashboard Aspire// local, et verifier qu'il y apparait (onglet Traces).//// Indice : OtlpExporterOptions.Protocol = OtlpExportProtocol.HttpProtobuf,// TimeoutMilliseconds = 2000 (evite Dispose() pendant si collector absent).// `aspire start` depuis aspire-otel/SkOtel.AppHost expose le dashboard.//// Cible : un span nomme "BoucleFermeeOTLP" avec tags (boucle.mode, etc.)// ForceFlush(2000) avant Dispose() -- precaution kernel documentee cellule 14.Console.WriteLine("Exercice 5 a completer -- configurer AddOtlpExporter vers http://localhost:4317/v1/traces (HTTP/protobuf)");
Exercice 5 a completer -- configurer AddOtlpExporter vers http://localhost:4317/v1/traces (HTTP/protobuf)
Lecture de la cellule 15
Une fois executee, la cellule precedente envoie un span via OTLP vers le dashboard Aspire local. Trois details a observer :
le TraceId affiche dans la console DOIT etre identique a celui visible dans le dashboard (preuve que l’export a traverse le reseau) ;
le service.name vaut aspire03-otel-bridge (positionne par AddService(...) dans le ResourceBuilder) ; en production il viendrait de la variable d’environnement OTEL_SERVICE_NAME ;
les tags (boucle.mode, boucle.endpoint, boucle.kernel) demontrent que les attributs OTel semantiques sont preserves pendant l’export.
Si la console affiche TraceId=... mais que le dashboard reste vide, le collector Aspire n’est pas accessible. TimeoutMilliseconds = 2000 borne l’envoi. Verifier aspire ps ; relancer aspire start --non-interactive depuis aspire-otel/SkOtel.AppHost.
// Exercice 6 : pont Serilog -> OpenTelemetry//// Objectif : emettre des logs Serilog qui apparaissent dans le dashboard Aspire// comme events OTel, sur le meme flux que les traces.//// Indice : Serilog.Sinks.OpenTelemetry (nuget: 2.0.0) gere OtlpProtocol.HttpProtobuf.// En contexte : logOtlp = new LoggerConfiguration().Enrich.WithProperty("app",// "aspire03-bridge").WriteTo.OpenTelemetry(opts => { opts.Endpoint = ...; }).CreateLogger();// logOtlp.Dispose() en fin -- precaution identique a ForceFlush(2000).Console.WriteLine("Exercice 6 a completer -- configurer WriteTo.OpenTelemetry sur logOtlp et emettre 2 logs dans un span actif");
Exercice 6 a completer -- configurer WriteTo.OpenTelemetry sur logOtlp et emettre 2 logs dans un span actif
Lecture de la cellule 17
Une fois executee, la cellule precedente envoie deux evenements Serilog structures sur le meme endpoint OTLP HTTP/protobuf que les traces, via le sink Serilog.Sinks.OpenTelemetry. Les attributs poses par Enrich.WithProperty("app", ...) se retrouvent dans le dashboard Aspire, onglet Logs, avec les champs Utilisateur, Question, Tokens parses en colonnes filtrables.
Le pont preserve la correlation : un log emis pendant qu’un span est actif herite du TraceId et du SpanId courants. Dans le dashboard, cliquer sur un log redirige vers le span qui le contient – la meme demonstration cles qu’a la section 4, mais via le reseau.
// Exercice 4 : instrumentation BackgroundService + verification aspire otel spans//// Objectif : creer une `DemoWorker : BackgroundService` qui, dans `ExecuteAsync`,// ouvre un span `Aspire03.Worker.Tick`, emet une metrique `ticks_total`// incrementee a chaque iteration, et log un evenement Serilog. Brancher// `AddOpenTelemetry().WithTracing().WithMetrics()` sur l'host builder.// Verifier dans le dashboard Aspire (onglet Traces/Metrics/Logs) que les trois piliers// apparaissent pour le service `aspire03-worker`.//// Indice : IServiceCollection n'est pas directement disponible dans cette cellule// (portee kernel) ; penser a basculer sur `Host.CreateApplicationBuilder()` plutot// que `new ServiceCollection().BuildServiceProvider()`.Console.WriteLine("Exercice 4 a completer -- definir DemoWorker : BackgroundService avec ExecuteAsync boucle, AddOpenTelemetry + AddHostedService");
Exercice 4 a completer -- definir DemoWorker : BackgroundService avec ExecuteAsync boucle, AddOpenTelemetry + AddHostedService
La boucle fermée, démonstration résolue : les trois piliers natifs
Les exercices 4, 5 et 6 ci-dessus restent à compléter : ils vous demandent de câbler vous-même l’exporteur OTLP, le pont Serilog et la BackgroundService. Cette section montre le même chemin en version résolue et exécutée, avec deux différences qui la distinguent de tout ce qui précède :
Les logs passent par le pont natif du SDK — WithLogging() route les ILogger vers OpenTelemetry, sans Serilog. Le notebook possède donc deux voies vers le même dashboard : le pont Serilog de l’exercice 6, et le pont natif d’ici.
Un span HTTP créé par l’instrumentation automatique — AddHttpClientInstrumentation() génère un span pour chaque requête sans qu’aucune ligne du code appelant n’instrumente quoi que ce soit : c’est la différence entre télémétrie manuelle (ActivitySource) et télémétrie automatique.
La boucle complète tient en trois gestes : démarrer un dashboard Aspire standalone (sans AppHost — le dashboard seul suffit à recevoir de l’OTLP), exporter les trois signaux d’un seul geste (UseOtlpExporter), puis relire le résultat par la CLI.
// Demarrage du dashboard Aspire standalone (idempotent).// S'il ecoute deja sur 4318, on le reutilise ; sinon on le lance en arriere-plan.using System.Net.Sockets;using System.Diagnostics;boolOtlpAlive(){try{usingvar probe =newTcpClient(); probe.Connect("127.0.0.1",4318);returntrue;}catch{returnfalse;}}Process? dashboardProc =null;if(OtlpAlive()){ Console.WriteLine("Un dashboard ecoute deja sur 4318 -- reutilise.");}else{var aspireTools = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),".dotnet","tools");var dashboardArgs ="dashboard run --allow-anonymous "+"--frontend-url http://localhost:18888 --otlp-grpc-url http://localhost:4317 "+"--otlp-http-url http://localhost:4318 --non-interactive --nologo";// Sous Windows, la CLI est aspire.cmd et passe par cmd.exe. cmd /c ne parse pas les guillemets// imbriques autour d'un .cmd : on appelle sans guillemets, ce qui reste correct tant que le// profil utilisateur (donc le chemin) ne contient pas d'espace. Ailleurs, aspire se lance directement.var psi = OperatingSystem.IsWindows()?newProcessStartInfo("cmd.exe","/c call "+ Path.Combine(aspireTools,"aspire.cmd")+" "+ dashboardArgs):newProcessStartInfo(Path.Combine(aspireTools,"aspire"), dashboardArgs); psi.UseShellExecute=false; psi.CreateNoWindow=true; psi.RedirectStandardOutput=true; psi.RedirectStandardError=true; dashboardProc = Process.Start(psi);var deadline = DateTime.UtcNow.AddSeconds(30);while(!OtlpAlive()&& DateTime.UtcNow< deadline) await Task.Delay(500);}Console.WriteLine(dashboardProc isnull?"Dashboard actif (reutilise) -- UI http://localhost:18888, OTLP http://localhost:4318": $"Dashboard demarre (pid {dashboardProc.Id}) -- UI http://localhost:18888, OTLP http://localhost:4318");
Le mini-hôte ci-dessous rassemble les trois piliers par le SDK natif :
WithTracing() écoute notre ActivitySource manuelle et l’instrumentation HTTP automatique ;
WithMetrics() exporte le compteur ticks du worker ;
WithLogging() route les ILogger du host vers OTel — sans Serilog ;
UseOtlpExporter(OtlpExportProtocol.Grpc, ...) exporte les trois signaux d’un seul appel (c’est l’API unifiée : inutile de câbler un exporteur par pilier).
Le worker boucle toutes les 700 ms : un span Tick manuel, un compteur, un log natif ; au deuxième tour il interroge en HTTP l’UI du dashboard elle-même — produisant un span GET qu’aucune ligne de code n’a demandé.
// Le mini-hote : le pipeline natif rassemble les trois piliers d'un seul geste// (WithTracing + WithMetrics + WithLogging + UseOtlpExporter), puis une boucle// de worker genere un span manuel, un compteur et un log natif a chaque tour.using Microsoft.Extensions.DependencyInjection;using Microsoft.Extensions.Hosting;using Microsoft.Extensions.Logging;using OpenTelemetry;using OpenTelemetry.Exporter;using OpenTelemetry.Metrics;using OpenTelemetry.Resources;using OpenTelemetry.Trace;using System.Net.Http;using System.Threading;voidDemoTranche2(){var builderT2 = Host.CreateApplicationBuilder();// Le logger de cycle de vie imprime par defaut le chemin absolu du content root.// Il n'apporte rien a la demonstration : on garde les logs metier Tranche2 et// HTTP, mais on filtre cette categorie avant de construire le host. builderT2.Logging.AddFilter("Microsoft.Hosting.Lifetime", LogLevel.Warning); builderT2.Services.AddOpenTelemetry().ConfigureResource(r => r.AddService("aspire03-tranche2", serviceInstanceId: Environment.MachineName).AddAttributes(new[]{new KeyValuePair<string,object>("aspire03.tranche2","natif")})).WithTracing(t => t.AddSource("Aspire03.Tranche2").AddHttpClientInstrumentation()// Le kernel .NET Interactive maintient une Activity parent ambiante non// echantillonnee : le sampler ParentBased par defaut refuserait chaque span// local et StartActivity renverrait null. On force l'echantillonnage, comme// le fait la section 2 (voir note kernel - SetSampler(AlwaysOnSampler))..SetSampler(newAlwaysOnSampler())).WithMetrics(m => m.AddMeter("Aspire03.Tranche2")).WithLogging().UseOtlpExporter(OtlpExportProtocol.Grpc,newUri("http://localhost:4317")); builderT2.Services.AddHttpClient();usingvar hostT2 = builderT2.Build(); hostT2.StartAsync().GetAwaiter().GetResult();usingvar meter =new System.Diagnostics.Metrics.Meter("Aspire03.Tranche2");var ticks = meter.CreateCounter<int>("aspire03_ticks_total");usingvar source =new System.Diagnostics.ActivitySource("Aspire03.Tranche2");var logger = hostT2.Services.GetRequiredService<ILoggerFactory>().CreateLogger("Tranche2");var httpFactory = hostT2.Services.GetRequiredService<IHttpClientFactory>();var n =0;for(var i =0; i <3; i++){usingvar span = source.StartActivity("Tick"); ticks.Add(1,new KeyValuePair<string,object>("tick.n",++n)); logger.LogInformation("tick natif ILogger numero {N}", n);if(n ==2){// Requete reelle vers l'UI du dashboard : le span GET est cree// par AddHttpClientInstrumentation, sans instrumentation manuelle.usingvar client = httpFactory.CreateClient();var html = client.GetStringAsync("http://localhost:18888").GetAwaiter().GetResult(); logger.LogInformation("dashboard repond {Len} octets (span HTTP automatique)", html.Length);} Thread.Sleep(700);} hostT2.StopAsync().GetAwaiter().GetResult(); Console.WriteLine("Mini-hote arrete : 3 piliers exportes vers le dashboard.");}DemoTranche2();
info: Tranche2[0]
tick natif ILogger numero 1
info: Tranche2[0]
tick natif ILogger numero 2
info: System.Net.Http.HttpClient.Default.LogicalHandler[100]
Start processing HTTP request GET http://localhost:18888/
info: System.Net.Http.HttpClient.Default.ClientHandler[100]
Sending HTTP request GET http://localhost:18888/
info: System.Net.Http.HttpClient.Default.ClientHandler[101]
Received HTTP response headers after 289.4099ms - 200
info: System.Net.Http.HttpClient.Default.LogicalHandler[101]
End processing HTTP request after 305.6676ms - 200
info: Tranche2[0]
dashboard repond 5156 octets (span HTTP automatique)
info: Tranche2[0]
tick natif ILogger numero 3
Mini-hote arrete : 3 piliers exportes vers le dashboard.
#nullable enable// Relecture par la CLI : les spans ET les logs du notebook, relus depuis le dashboard.using System.Text.Json;using System.Threading;stringRunAspireCli(paramsstring[] args){var aspireTools = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),".dotnet","tools");// Meme bord cmd /c sous Windows : 'call' sans guillemets (chemin sans espace) pour eviter// le mauvais parsing. Ailleurs, aspire se lance directement.var psi = OperatingSystem.IsWindows()?newProcessStartInfo("cmd.exe","/c call "+ Path.Combine(aspireTools,"aspire.cmd")+" "+string.Join(" ", args)):newProcessStartInfo(Path.Combine(aspireTools,"aspire"),string.Join(" ", args)); psi.UseShellExecute=false; psi.CreateNoWindow=true; psi.RedirectStandardOutput=true; psi.RedirectStandardError=true;usingvar p = Process.Start(psi)!;var stdout = p.StandardOutput.ReadToEnd(); p.WaitForExit(30_000);return stdout;}voidRelire(){// Le dashboard ingere le lot OTLP avec un delai variable (export en batch + ingestion// des traces plus lente que les logs) : on interroge jusqu'a obtenir des spans, ~12 s max. JsonDocument?querySpans(string search){for(var tentative =0; tentative <12; tentative++){ Thread.Sleep(1000);var spansJson =RunAspireCli("otel","spans","--dashboard-url","http://localhost:18888","--search", search,"--format","Json","--non-interactive","--nologo","-n","12");if(string.IsNullOrWhiteSpace(spansJson))continue;var doc = JsonDocument.Parse(spansJson);if(doc.RootElement.GetArrayLength()>0)return doc; doc.Dispose();}returnnull;}// Deux cibles : le span MANUEL (nom "Tick", notre ActivitySource) et le span// AUTOMATIQUE (le GET re-instrumente par AddHttpClientInstrumentation).foreach(var(libelle, search)innew[]{("manuels (ActivitySource)","Tick"),("automatiques (HTTP)","GET")}){usingvar doc =querySpans(search);if(doc isnull){ Console.WriteLine($"aucun span {libelle} relu apres 12 s");continue;} Console.WriteLine($"spans {libelle} : "+ doc.RootElement.GetArrayLength()); Console.WriteLine("kind nom dur(ms) traceId");foreach(var s in doc.RootElement.EnumerateArray()){var kind = s.GetProperty("kind").GetString();var nom = s.GetProperty("name").GetString();var duree = s.GetProperty("durationMs").GetInt32();var trace = s.GetProperty("traceId").GetString()!; Console.WriteLine($"{kind,-10} {nom,-42} {duree,8} {trace[..16]}...");} Console.WriteLine();}// Les logs natifs : message template + attributs substitues (N, et le traceId herite).var logsJson =RunAspireCli("otel","logs","aspire03-tranche2","--dashboard-url","http://localhost:18888","--format","Json","--non-interactive","--nologo","-n","4");if(string.IsNullOrWhiteSpace(logsJson)){ Console.WriteLine("aucun log relu (vide)");}else{usingvar logsDoc = JsonDocument.Parse(logsJson); Console.WriteLine(); Console.WriteLine("logs natifs relus : "+ logsDoc.RootElement.GetArrayLength());foreach(var l in logsDoc.RootElement.EnumerateArray()){var gravite = l.GetProperty("severity").GetString();var message = l.GetProperty("message").GetString();var attrs =string.Join(", ", l.GetProperty("attributes").EnumerateObject().Select(a => a.Name+"="+ a.Value.GetString())); Console.WriteLine($"[{gravite}] {message} ({attrs})");}}}Relire();
spans manuels (ActivitySource) : 3
kind nom dur(ms) traceId
Internal Tick 708 e92d3cde3a5ab6e2...
Internal Tick 1013 e92d3cde3a5ab6e2...
Internal Tick 700 e92d3cde3a5ab6e2...
spans automatiques (HTTP) : 2
kind nom dur(ms) traceId
Client GET 133 e92d3cde3a5ab6e2...
Client GET 154 e92d3cde3a5ab6e2...
logs natifs relus : 4
[Information] Received HTTP response headers after {ElapsedMilliseconds}ms - {StatusCode} (ElapsedMilliseconds=289.4099, StatusCode=200)
[Information] End processing HTTP request after {ElapsedMilliseconds}ms - {StatusCode} (ElapsedMilliseconds=305.6676, StatusCode=200)
[Information] dashboard repond {Len} octets (span HTTP automatique) (Len=5156)
[Information] tick natif ILogger numero {N} (N=3)
Lecture de la demonstration
Trois preuves lisibles dans les sorties ci-dessus :
Le span Tick est notre télémétrie manuelle — créée par ActivitySource, comme aux sections précédentes.
Les spans GET sont la télémétrie automatique — aucune ligne du worker ne demande leur création : AddHttpClientInstrumentation() les a générés pour la requête HTTP du deuxième tour. Ils sont deux car l’UI répond une redirection (302 vers /structuredlogs) : le client suit le lien, et chaque requête devient son propre span. Leurs durées (quelques dizaines de ms) sont celles de la vraie requête réseau.
Les logs tick natif ILogger sont arrivés par le pont natif WithLogging() — sans Serilog. Leurs attributs {N} substitués confirment la structuration, et le traceId de chaque log est celui du span Tick pendant lequel il a été émis : la corrélection est automatique, exactement comme à la section 4 — mais cette fois par le SDK, sans enrichisseur Serilog.
La CLI a lu tout cela depuis le dashboard, preuve que les trois piliers ont traversé le réseau OTLP. Dans le dashboard (onglets Traces, Metrics, Logs), le même flux est filtrable à la souris.
// Arret propre : tuer l'arborescence du dashboard SI c'est ce notebook qui l'a demarre.if(dashboardProc is{ HasExited:false}){if(OperatingSystem.IsWindows()){usingvar killer = Process.Start("taskkill", $"/PID {dashboardProc.Id} /T /F"); killer?.WaitForExit(5000);}else{ dashboardProc.Kill(entireProcessTree:true);// taskkill n'existe que sous Windows dashboardProc.WaitForExit(5000);} Console.WriteLine($"Dashboard (pid {dashboardProc.Id}) arrete.");}else{ Console.WriteLine("Dashboard externe (deja actif avant ce notebook) -- laisse en vie.");}
Dashboard (pid 7639) arrete.
6. Exercices
Les trois exercices font varier un levier a la fois : un tag metier (exercice 1), un enrichissement global (exercice 2), une metrique (exercice 3 – le troisieme pilier, volontairement laisse de cote jusqu’ici).
Les exercices 4 a 6 enverraient les signaux au dashboard Aspire via OTLP : l’export d’un span (4), le pont Serilog->OTel (5), et la triple pile trace+metric+log sur un service de fond (6). Ils supposent un AppHost Aspire 13 lance localement (aspire start depuis aspire-otel/SkOtel.AppHost).
Exercice 1 : span metier chiffre
Ecrire une fonction MesurerReponse qui ouvre un span via SpanCallSite, interroge un modele factice (un Thread.Sleep de duree aleatoire fait l’affaire), puis pose le tag reponse.tokens avec un nombre entier. Verifier dans l’export console que le tag apparait sur le span.
// Exercice 1 : a completer// Indice : Random.Shared.Next(50, 200) pour la duree ; question.Length * 3 pour les tokens.// Etape 1 : ouvrir le span avec SpanCallSite()// Etape 2 : simuler l'appel (Thread.Sleep)// Etape 3 : poser le tag reponse.tokens puis retourner la dureeConsole.WriteLine("Exercice 1 a completer");
Exercice 1 a completer
Exercice 2 : champ global d’environnement
Ajouter au logger un enrichissement d’environnement (par exemple env = "notebook") de sorte que toutes les lignes posterieures portent le champ. Emettre deux evenements et verifier qu’ils portent tous les deux le champ sans qu’on le re-ecrive dans le template.
// Exercice 2 : a completer// Indice : repartir de new LoggerConfiguration(), chainer .Enrich.WithProperty("env", "notebook")// Etape 1 : construire log2 avec l'enrichissement supplementaire (garder app=aspire03-demo)// Etape 2 : emettre deux Information() avec des templates differents// Etape 3 : lire les deux lignes et verifier que env est present partoutConsole.WriteLine("Exercice 2 a completer");
Exercice 2 a completer
Exercice 3 : la metrique, troisieme pilier
Logs et traces couverts — reste metrics. Creer un Meter nomme Aspire03.Metrics avec un Counter<int> appele questions_posees, l’incrémenter deux fois avec un tag utilisateur different, et construire un MeterProvider console pour l’observer.
// Exercice 3 : a completer// Indice : new Meter("Aspire03.Metrics") puis CreateCounter<int>("questions_posees")// Etape 1 : creer le Meter et le Counter// Etape 2 : Add(1, new KeyValuePair<string, object?>("utilisateur", "etudiant-42")) x2 utilisateurs// Etape 3 : var mp = Sdk.CreateMeterProviderBuilder().AddMeter("Aspire03.Metrics").AddConsoleExporter().Build();// puis mp.ForceFlush() pour declencher l'export (meme raison que le Simple processor des traces)Console.WriteLine("Exercice 3 a completer");
Exercice 3 a completer
Conclusion
Ce notebook a execute le vrai stack d’observabilite .NET moderne, sans orchestrateur :
Serilog (A8) : templates structures, enrichissement global, formateur compact JSON — les logs sont des objets indexables ;
OpenTelemetry (A9) : ActivitySource + TracerProvider + exporteur console — les traces avec identifiants, hierarchie et durees ;
ActivitySource call-site (A10) : le helper Caller* pose les tags code.function / code.file.path / code.line.number injectes par le compilateur — l’instrumentation ne peut plus mentir sur sa position ;
la correlation : @tr/@sp Serilog = TraceId/SpanId des spans, sans glue — la demonstration cles de la section 4.
La marche suivante est l’orchestrateur : dans une AppHost Aspire, l’exporteur devient OTLP, le dashboard (port 15050) et la CLI aspire otel spans remplacent l’export console — voir le dossier aspire-otel/SkOtel.AppHost du repo et le notebook Aspire 2 pour le branchement d’un stack reel.