A treia parte din seria despre observabilitate. Log-urile din partea a doua au context, dar rămân insulare per serviciu. Distributed tracing le leagă: același trace id, de la request-ul HTTP până în worker-ul care consumă mesajul.
De ce OpenTelemetry și nu SDK-ul clasic
Application Insights are un SDK clasic de instrumentare, dar direcția oficială Microsoft e OpenTelemetry: standardul deschis pentru trace-uri, metrici și log-uri. Motivele practice:
- Instrumentezi o singură dată — schimbi backend-ul (Application Insights azi, altceva mâine) schimbând exporter-ul, nu codul
- Instrumentări automate mature — ASP.NET Core, HttpClient, SDK-urile Azure (Cosmos, Service Bus, Blob) emit span-uri fără cod din partea ta
- Baza .NET e deja OTel —
ActivityșiActivitySourcedin runtime sunt primitivele OpenTelemetry; standardul e nativ, nu adăugat
dotnet add package Azure.Monitor.OpenTelemetry.AspNetCore
ASP.NET Core: configurarea completă
// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenTelemetry()
.UseAzureMonitor(options =>
{
// Connection string din configurare (App Settings / Key Vault)
options.ConnectionString =
builder.Configuration["ApplicationInsights:ConnectionString"];
})
.WithTracing(tracing => tracing
.AddSource("ChatApi.*") // sursele custom (mai jos)
.SetSampler(new ParentBasedSampler(
new TraceIdRatioBasedSampler(0.25)))) // 25% la volum mare
.WithMetrics(metrics => metrics
.AddMeter("ChatApi.*"));
UseAzureMonitor activează automat instrumentarea pentru ASP.NET Core (fiecare request devine span), HttpClient (fiecare apel extern devine span de dependency) și SDK-urile Azure. Fără nicio linie suplimentară, apelurile către Cosmos DB și Service Bus apar deja în trace-uri, cu durata lor.
Sampler-ul merită atenție: ParentBasedSampler respectă decizia părintelui — dacă request-ul inițial a fost eșantionat, toate span-urile lui din toate serviciile sunt păstrate. Trace-uri complete sau deloc, niciodată găurite.
Azure Functions Isolated Worker
Isolated Worker cere pachetul dedicat și configurarea în HostBuilder:
dotnet add package Microsoft.Azure.Functions.Worker.OpenTelemetry
dotnet add package Azure.Monitor.OpenTelemetry.Exporter
// Program.cs (Functions Isolated Worker)
var host = new HostBuilder()
.ConfigureFunctionsWebApplication()
.ConfigureServices(services =>
{
services.AddOpenTelemetry()
.UseFunctionsWorkerDefaults() // instrumentarea runtime-ului Functions
.UseAzureMonitorExporter();
})
.Build();
await host.RunAsync();
Un detaliu de configurare: în Application Settings al Function App-ului, dezactivează colectarea duplicată a host-ului clasic dacă migrezi de pe SDK-ul vechi — altfel vezi fiecare invocație de două ori.
Propagarea prin Service Bus: firul care nu se rupe
Partea cu adevărat valoroasă: trace-ul traversează mesajele. SDK-ul Service Bus propagă automat contextul W3C (traceparent) în proprietățile mesajului, iar consumatorul îl reia. API-ul care publică și worker-ul care consumă apar în același trace, deși rulează în procese diferite, minute mai târziu.
Condiția: consumatorul să pornească span-ul ca link sau copil al contextului din mesaj. Cu ServiceBusProcessor și instrumentarea automată activă, asta se întâmplă implicit. Pentru procesare manuală:
private async Task OnMessageAsync(ProcessMessageEventArgs args)
{
// Contextul de trace vine din proprietatile mesajului (Diagnostic-Id)
// Cu instrumentarea Azure SDK activa, span-ul de procesare
// e legat automat de trace-ul publicarii.
using var activity = ChatApiActivitySource.Instance
.StartActivity("ProcessOrderMessage", ActivityKind.Consumer);
activity?.SetTag("messaging.message_id", args.Message.MessageId);
activity?.SetTag("tenant.id",
args.Message.ApplicationProperties["TenantId"]?.ToString());
await handler.HandleAsync(/* ... */);
}
Rezultatul în Application Insights (Transaction search / End-to-end view): un singur arbore — request HTTP → publicare mesaj → (gap-ul de așteptare în coadă, vizibil!) → procesare în worker → apeluri Cosmos din worker. Gap-ul din coadă e diagnosticul gratuit: latența „misterioasă” e adesea timp de așteptare, nu procesare.
Span-uri custom cu ActivitySource
Instrumentarea automată acoperă infrastructura. Pentru operațiile de business (căutarea în cache-ul semantic, apelul de embeddings), definești surse proprii:
public static class ChatApiActivitySource
{
// Numele trebuie sa corespunda cu AddSource("ChatApi.*") din config
public static readonly ActivitySource Instance = new("ChatApi.Core");
}
public class SemanticCacheService
{
public async Task<CachedAnswer?> TryGetAsync(string query, string tenantId)
{
using var activity = ChatApiActivitySource.Instance
.StartActivity("SemanticCache.Lookup");
activity?.SetTag("tenant.id", tenantId);
var embedding = await _embeddings.GetEmbeddingAsync(query);
var match = await _cacheRepository.FindSimilarAsync(embedding, tenantId);
// Rezultatul devine atribut -- interogabil in KQL
activity?.SetTag("cache.hit", match is not null);
activity?.SetTag("cache.similarity", match?.Similarity ?? 0);
return match;
}
}
Acum fiecare trace de chat arată explicit: lookup-ul în cache (hit sau miss, cu similaritatea), apoi — doar la miss — apelul scump către OpenAI. Rata de cache hit devine vizibilă direct din trace-uri, per tenant.
Două reguli de igienă: numele span-ului e operația („SemanticCache.Lookup”), nu valorile („Lookup query X” ar exploda cardinalitatea); valorile merg în tag-uri. Și excepțiile: activity?.SetStatus(ActivityStatusCode.Error) înainte de re-throw, ca span-ul să apară roșu în vizualizare.
Ce urmează
Trace-urile arată drumul; în partea a patra măsurăm sistematic: custom metrics cu Meter (rata de cache hit, mesaje per tenant, RU consumate), dashboards în Azure și alertele care contează.
Întrebări? Scrie-mi la contact@ludoprogramming.com.