In questo articolo

🔭 Perché un LLM non si osserva come un database

Su una query SQL le domande operative sono due: quanto ci ha messo e ha fallito? Su una chiamata a un modello le domande diventano cinque: quanto ci ha messo, quanti token ha letto, quanti ne ha scritti, quanti giri di tool ha fatto e quanto mi è costata. Le prime due metriche le dà l'infrastruttura, le altre tre no.

C'è poi un problema di forma. Una richiesta HTTP a un LLM con function calling non è una chiamata sola: è un ciclo. Il modello risponde chiedendo un tool, il tuo codice lo esegue, rimanda il risultato, il modello risponde di nuovo. Se strumenti solo l'HTTP in uscita vedi tre span slegati e nessuno che racconta la richiesta intera.

Cosa vedi senza e con strumentazione GenAI

Solo istrumentazione HTTP

  • Tre POST verso :11434, nessun nesso fra loro
  • Nessun conteggio di token: è dentro il body
  • I tool eseguiti non compaiono da nessuna parte
  • Il costo non esiste come dato

Span gen_ai + metriche

  • Uno span padre che copre l'intero ciclo
  • Token in ingresso e in uscita come attributi
  • Uno span figlio per ogni esecuzione di tool
  • Un contatore di costo aggregabile per modello

La differenza non è la quantità di dati, è che diventano una storia sola.

📐 Le semantic conventions GenAI, in breve

OpenTelemetry ha una specifica dedicata alle chiamate ai modelli: le semantic conventions for Generative AI. Definiscono i nomi degli span (chat, execute_tool, embeddings), gli attributi obbligatori e le metriche standard. È ancora marcata experimental, ma è già ciò che ogni backend — Aspire, Grafana, Application Insights, Datadog — si aspetta di ricevere.

Il vantaggio pratico è la portabilità: se emetti questi nomi, la dashboard sa già raggrupparli senza che tu configuri nulla. La versione implementata da Microsoft.Extensions.AI 10.8 è la v1.41 della spec.

  • Span `chat {model}`: apre e chiude sull'intera operazione di conversazione, non sulla singola HTTP.
  • Attributi: `gen_ai.operation.name`, `gen_ai.provider.name`, `gen_ai.request.model`, `gen_ai.response.model`.
  • Uso: `gen_ai.usage.input_tokens` e `gen_ai.usage.output_tokens` sullo span, più l'istogramma `gen_ai.client.token.usage`.
  • Latenza: l'istogramma `gen_ai.client.operation.duration`, in secondi — è lì che leggi il p95.
  • Contenuti: prompt e risposta sono opzionali e disattivati di default, perché contengono ciò che ha scritto l'utente.
Attributi di uno span gen_ai · testo
span name       chat llama3.2:3b
duration        2.41s

gen_ai.operation.name        chat
gen_ai.provider.name         ollama
gen_ai.request.model         llama3.2:3b
gen_ai.response.model        llama3.2:3b
gen_ai.usage.input_tokens    412
gen_ai.usage.output_tokens   128

# aggiunto dal middleware che mi scrivo più avanti
llmobs.usage.cost_usd        0.000139

OpenTelemetry · Semantic conventions for GenAI

Codice completo nel repo: https://github.com/fscamuzzi/llm-observability-dotnet-opentelemetry

🏗️ Cosa costruisco

Un'API di assistenza clienti: un endpoint POST /chat che risponde usando un modello locale e un tool che legge lo stato di un ordine. Volutamente banale come funzionalità, perché il pezzo interessante è tutto quello che le sta intorno.

Il modello gira su Ollama in Docker, la telemetria esce via OTLP verso la dashboard Aspire — anch'essa un container, quindi non installo nulla sul portatile a parte Docker. L'API resta sull'host, così posso ricompilarla senza ricostruire immagini.

  • Runtime: .NET 10, Minimal API, nessun controller.
  • Astrazione LLM: Microsoft.Extensions.AI con IChatClient e la sua pipeline di middleware.
  • Provider: OllamaSharp verso llama3.2:3b in locale.
  • Telemetria: OpenTelemetry .NET per tracce, metriche e log, esportate su OTLP/gRPC.
  • Visualizzazione: dashboard Aspire standalone, container ufficiale Microsoft.

🐳 Passo 1 · Ollama e la dashboard Aspire in Docker

La dashboard Aspire esiste come immagine autonoma: è un endpoint OTLP con sopra una UI, e non richiede un AppHost Aspire nel progetto. Espone la UI su 18888 e l'ingestione OTLP su 18889, che pubblico sulla 4317 — la porta convenzionale che ogni SDK si aspetta.

Il servizio ollama-init è il trucco che evita la delusione della prima richiesta: appena Ollama passa l'healthcheck, scarica il modello e muore. Senza, il primo POST resterebbe appeso per minuti mentre il container si scarica due gigabyte in silenzio.

docker-compose.yml · YAML
services:
  ollama:
    image: ollama/ollama:latest
    ports: ["11434:11434"]
    volumes: [ollama-data:/root/.ollama]
    healthcheck:
      test: ["CMD-SHELL", "ollama list >/dev/null 2>&1 || exit 1"]
      interval: 10s
      retries: 12
      start_period: 20s

  # one-shot: scarica il modello appena Ollama è sano, poi esce
  ollama-init:
    image: ollama/ollama:latest
    depends_on:
      ollama:
        condition: service_healthy
    environment:
      OLLAMA_HOST: http://ollama:11434
    entrypoint: ["/bin/sh", "-c", "ollama pull ${OLLAMA_MODEL:-llama3.2:3b}"]
    restart: "no"

  aspire-dashboard:
    image: mcr.microsoft.com/dotnet/aspire-dashboard:9.0
    ports:
      - "18888:18888"   # UI
      - "4317:18889"    # OTLP/gRPC
    environment:
      DOTNET_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS: "true"
      ASPNETCORE_URLS: http://+:18888

volumes:
  ollama-data:
Codice completo nel repo: https://github.com/fscamuzzi/llm-observability-dotnet-opentelemetry

⬆️ Passo 2 · Avviare lo stack e verificarlo

Tre comandi e l'infrastruttura è in piedi. Vale la pena guardare i log di ollama-init fino al `success`: finché non compare, il modello non c'è ancora e ogni chiamata risponderebbe con un 404 del server Ollama.

La variabile DOTNET_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS elimina il token di login della dashboard. Su un portatile è comodità; su una macchina condivisa è un errore, perché quella UI mostra i prompt di tutti.

Lo stack in tre comandi
Terminale con lo stack che parte: docker compose up -d crea la rete e avvia i container llmobs-ollama, llmobs-aspire-dashboard e llmobs-ollama-init; i log di ollama-init mostrano il pull del modello fino a success; infine dotnet run avvia l'API in ascolto su localhost e ricorda l'indirizzo della dashboard su localhost:18888.

Aspetta il success di ollama-init prima della prima richiesta.

📦 Passo 3 · I pacchetti e i nomi della telemetria

I pacchetti sono due famiglie: Microsoft.Extensions.AI con il provider OllamaSharp, e l'SDK OpenTelemetry con l'esportatore OTLP e le instrumentation per ASP.NET Core, HttpClient e runtime.

Poi c'è una cosa che sembra pedanteria e invece è la causa numero uno delle dashboard vuote: il nome della sorgente. `UseOpenTelemetry()` accetta un `sourceName`; se non glielo passi usa un default interno, e se nel `AddSource()` scrivi un nome diverso i tuoi span esistono ma nessuno li ascolta. Io lo dichiaro una volta in una classe di costanti e lo uso da entrambe le parti.

Terminale · pacchetti NuGet
dotnet add package Microsoft.Extensions.AI
dotnet add package OllamaSharp

dotnet add package OpenTelemetry.Extensions.Hosting
dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol
dotnet add package OpenTelemetry.Instrumentation.AspNetCore
dotnet add package OpenTelemetry.Instrumentation.Http
dotnet add package OpenTelemetry.Instrumentation.Runtime
Codice completo nel repo: https://github.com/fscamuzzi/llm-observability-dotnet-opentelemetry

🏷️ Passo 4 · Un solo posto per i nomi

Tre costanti e il problema di allineamento sparisce. Registro anche il nome di default della libreria: se un domani un'altra pipeline dell'app dimentica il `sourceName`, la telemetria continua ad arrivare invece di svanire.

Observability/TelemetryNames.cs · C#
namespace LlmObservability.Api.Observability;

// Unica fonte di verità per i nomi emessi dall'app: è ciò che tiene
// allineati UseOpenTelemetry() e AddSource()/AddMeter().
public static class TelemetryNames
{
    // ActivitySource + Meter passati esplicitamente a UseOpenTelemetry().
    public const string ChatSource = "LlmObservability.Ai";

    // Meter del middleware di costo (custom, fuori dalle semantic conventions).
    public const string CostMeter = "LlmObservability.Cost";

    // ActivitySource dell'app per gli span aperti a mano.
    public const string AppSource = "LlmObservability.Api";

    // Nome usato da Microsoft.Extensions.AI quando non passi sourceName.
    public const string LibraryDefaultSource = "Experimental.Microsoft.Extensions.AI";
}
Codice completo nel repo: https://github.com/fscamuzzi/llm-observability-dotnet-opentelemetry

🔌 Passo 5 · Registrare tracce, metriche e log

`AddOpenTelemetry()` costruisce i tre pilastri. La parte che decide se la dashboard sarà utile o no è il resource attribute service.name: è il campo su cui ogni backend raggruppa, e sbagliarlo significa ritrovarsi tutto sotto «unknown_service».

Le instrumentation di ASP.NET Core e HttpClient non sono decorative: la prima dà lo span radice della richiesta, la seconda mostra la chiamata effettiva a Ollama sotto lo span gen_ai. Senza, il waterfall ha un buco proprio dove serve.

I log passano dallo stesso esportatore, così un warning e lo span che l'ha prodotto condividono il trace id e la dashboard li mette in relazione da sola.

Observability/ObservabilityExtensions.cs · C#
// Registra l'intero stack OpenTelemetry più gli oggetti che serve
// alla pipeline AI (listino prezzi, calcolatore, Meter del costo).
public static IHostApplicationBuilder AddLlmObservability(this IHostApplicationBuilder builder)
{
    // 1) Listino e calcolatore, condivisi con il middleware di costo.
    builder.Services.Configure<PricingOptions>(builder.Configuration.GetSection(PricingOptions.SectionName));
    builder.Services.AddSingleton(sp =>
        new CostCalculator(sp.GetRequiredService<IOptions<PricingOptions>>().Value));

    // 2) Il Meter che porta il contatore custom: singleton, nome stabile.
    builder.Services.AddSingleton(_ => new Meter(TelemetryNames.CostMeter));

    // 3) Chi sta emettendo: service.name è il campo su cui raggruppa tutto.
    var serviceName = builder.Configuration["Otel:ServiceName"] ?? "llm-observability-api";
    var otlpEndpoint = builder.Configuration["Otel:Endpoint"] ?? "http://localhost:4317";

    builder.Services.AddOpenTelemetry()
        .ConfigureResource(r => r.AddService(serviceName))
        .WithTracing(tracing => tracing
            .AddAspNetCoreInstrumentation()   // span radice HTTP
            .AddHttpClientInstrumentation()   // la chiamata vera a Ollama
            .AddSource(TelemetryNames.ChatSource)
            .AddSource(TelemetryNames.LibraryDefaultSource)
            .AddSource(TelemetryNames.AppSource)
            .AddOtlpExporter(o => o.Endpoint = new Uri(otlpEndpoint)))
        .WithMetrics(metrics => metrics
            .AddAspNetCoreInstrumentation()
            .AddHttpClientInstrumentation()
            .AddRuntimeInstrumentation()
            .AddMeter(TelemetryNames.ChatSource)          // istogrammi token/durata
            .AddMeter(TelemetryNames.LibraryDefaultSource)
            .AddMeter(TelemetryNames.CostMeter)           // il contatore di costo
            .AddOtlpExporter(o => o.Endpoint = new Uri(otlpEndpoint)));

    // 4) Log sullo stesso canale: warning e span correlati per trace id.
    builder.Logging.AddOpenTelemetry(logging =>
    {
        logging.IncludeFormattedMessage = true;
        logging.IncludeScopes = true;
        logging.AddOtlpExporter(o => o.Endpoint = new Uri(otlpEndpoint));
    });

    return builder;
}
Codice completo nel repo: https://github.com/fscamuzzi/llm-observability-dotnet-opentelemetry

🧅 Passo 6 · La pipeline IChatClient e l'ordine che conta

`ChatClientBuilder` compone middleware come fa ASP.NET Core con la pipeline HTTP, ma con una regola che va imparata a memoria: in `Build()` i factory vengono applicati in ordine inverso, quindi il primo registrato è il più esterno.

La conseguenza è concreta. Se metti `UseOpenTelemetry()` per ultimo finisce dentro il ciclo dei tool: lo span misura una singola iterazione e la latenza reale della richiesta — quella che il tuo utente subisce — non compare in nessun grafico. Registrandolo per primo, invece, lo span abbraccia tutti i giri di function calling.

Subito dentro metto il client che calcola il costo, così i suoi tag finiscono sullo span già aperto dallo strato sopra. Più in basso il tool loop, e infine il provider Ollama.

Ai/ChatClientExtensions.cs · C#
// ChatClientBuilder avvolge in ordine inverso: il PRIMO stadio registrato
// è il PIÙ ESTERNO. OpenTelemetry misura quindi l'intera chiamata,
// giri di tool compresi, e lo stadio di costo gira dentro il suo span.
public static IHostApplicationBuilder AddInstrumentedChatClient(this IHostApplicationBuilder builder)
{
    builder.Services.Configure<OllamaOptions>(builder.Configuration.GetSection(OllamaOptions.SectionName));

    // Prompt e risposte sugli span: oro in sviluppo, incidente GDPR in produzione.
    var enableSensitiveData = builder.Configuration.GetValue("Otel:EnableSensitiveData", false);

    builder.Services.AddChatClient(sp =>
    {
        var options = sp.GetRequiredService<IOptions<OllamaOptions>>().Value;
        return new OllamaApiClient(new Uri(options.Endpoint), options.Model);
    })
        // Più esterno: span e istogrammi coprono tutto quello che sta sotto.
        .UseOpenTelemetry(
            sourceName: TelemetryNames.ChatSource,
            configure: c => c.EnableSensitiveData = enableSensitiveData)
        // Poi il costo, così i suoi tag atterrano sullo span aperto sopra.
        .Use((inner, sp) => new CostTrackingChatClient(
            inner,
            sp.GetRequiredService<CostCalculator>(),
            sp.GetRequiredService<Meter>(),
            sp.GetRequiredService<ILogger<CostTrackingChatClient>>()))
        // Più interno: il ciclo dei tool resta dentro lo span.
        .UseFunctionInvocation();

    return builder;
}
Codice completo nel repo: https://github.com/fscamuzzi/llm-observability-dotnet-opentelemetry

🪆 L'annidamento, visto da fuori

Detto in una figura: quattro strati concentrici, dal client che apre lo span fino al provider che parla HTTP con Ollama. È l'unico schema da avere in testa quando si aggiunge un middleware nuovo — caching, retry, rate limiting — perché la posizione decide cosa finisce dentro la misura e cosa resta fuori.

Chi avvolge chi
Schema dell'annidamento della pipeline: a sinistra il codice di AddChatClient con UseOpenTelemetry, Use del CostTrackingChatClient e UseFunctionInvocation; a destra quattro riquadri concentrici mostrano che OpenTelemetryChatClient è il più esterno e apre lo span, dentro c'è CostTrackingChatClient che prezza i token, poi FunctionInvokingChatClient che esegue i tool e infine OllamaApiClient come provider.

Il primo stadio registrato è il più esterno: la posizione decide cosa entra nella misura.

💰 Passo 7 · Il contatore di costo che la libreria non ha

Token e latenza li dà la libreria; il costo no, perché dipende dal listino del tuo provider. Lo aggiungo con un `DelegatingChatClient`: legge lo `UsageDetails` della risposta, lo moltiplica per il prezzo, incrementa un contatore e scrive il valore anche sullo span corrente.

Il pezzo che uso più spesso è lo shadow pricing: il modello che risponde è quello locale, gratis, ma il listino con cui valorizzo i token è quello del modello cloud che userei in produzione. Il contatore diventa così una stima di quanto costerebbe lo stesso traffico dopo la migrazione, misurata su richieste vere invece che su un foglio di calcolo.

Attenzione allo streaming: lì l'uso non arriva nella risposta ma dentro un `UsageContent` degli ultimi update. Se non lo accumuli, metà del traffico risulta gratis.

Ai/CostTrackingChatClient.cs · C#
// Stadio che trasforma i token dichiarati dal modello in denaro. Sta DENTRO
// UseOpenTelemetry(), quindi i tag finiscono sullo span gen_ai già aperto.
public sealed class CostTrackingChatClient : DelegatingChatClient
{
    private const string CostAttribute = "llmobs.usage.cost_usd";

    // Chiamata bloccante: delega e poi prezza l'uso che è tornato.
    public override async Task<ChatResponse> GetResponseAsync(
        IEnumerable<ChatMessage> messages,
        ChatOptions? options = null,
        CancellationToken cancellationToken = default)
    {
        var response = await base.GetResponseAsync(messages, options, cancellationToken);
        Record(response.ModelId, response.Usage);
        return response;
    }

    // In streaming l'uso arriva come UsageContent negli update: accumulo
    // e prezzo una volta sola alla fine dello stream.
    public override async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
        IEnumerable<ChatMessage> messages,
        ChatOptions? options = null,
        [EnumeratorCancellation] CancellationToken cancellationToken = default)
    {
        var totals = new UsageDetails();
        string? modelId = null;

        await foreach (var update in base.GetStreamingResponseAsync(messages, options, cancellationToken))
        {
            modelId ??= update.ModelId;
            var usages = update.Contents.OfType<UsageContent>().Select(c => c.Details);
            usages.ToList().ForEach(totals.Add);
            yield return update;
        }

        Record(modelId, totals);
    }

    // Calcola il costo, lo registra sul contatore e lo scrive sullo span.
    private void Record(string? modelId, UsageDetails? usage)
    {
        // 1) Guardia: alcuni provider non riportano l'uso — niente da prezzare.
        if (usage is null)
            return;

        var model = modelId ?? "unknown";
        var input = usage.InputTokenCount ?? 0;
        var output = usage.OutputTokenCount ?? 0;

        // 2) Quale listino valorizza questi token (shadow pricing).
        var billingModel = _calculator.ResolveBillingModel(model);
        var cost = _calculator.Estimate(billingModel, input, output);

        // 3) Un grafico piatto a zero è quasi sempre un errore di configurazione.
        if (cost == 0m && (input > 0 || output > 0))
            _logger.LogWarning("Nessun listino per il modello {BillingModel}: costo riportato a 0.", billingModel);

        // 4) Contatore con i tag su cui raggrupperai nella dashboard.
        _costCounter.Add((double)cost,
            new KeyValuePair<string, object?>("gen_ai.request.model", model),
            new KeyValuePair<string, object?>("llmobs.billing.model", billingModel));

        // 5) Stesso numero sullo span: una richiesta lenta dice anche quanto è costata.
        Activity.Current?.SetTag(CostAttribute, (double)cost);
    }
}
Codice completo nel repo: https://github.com/fscamuzzi/llm-observability-dotnet-opentelemetry

🧮 Il listino, tenuto fuori dal middleware

La matematica del prezzo la isolo in una classe pura: nessuna dipendenza, nessun `Activity`, nessun `Meter`. È la parte che si testa in due righe e che non voglio dover riverificare ogni volta che cambio la strumentazione.

`BillingModel` vuoto significa «valorizza con il listino del modello che ha risposto»; valorizzato, attiva lo shadow pricing. Un modello assente dal listino costa zero e non solleva eccezioni: la metrica resta valida, dice solo che è gratis.

Observability/CostCalculator.cs · C#
// Trasforma un conteggio di token in una stima monetaria usando il listino.
// Puro e senza dipendenze: è il pezzo che vale la pena testare.
public sealed class CostCalculator(PricingOptions pricing)
{
    // Modello il cui listino valorizza i token: quello configurato se c'è
    // (shadow pricing), altrimenti quello che ha risposto.
    public string ResolveBillingModel(string responseModel)
    {
        if (!string.IsNullOrWhiteSpace(pricing.BillingModel))
            return pricing.BillingModel;
        return responseModel;
    }

    // Costo in USD di una coppia input/output per il modello indicato.
    // Zero se il modello non è a listino: tipico di un modello locale.
    public decimal Estimate(string model, long inputTokens, long outputTokens)
    {
        // 1) Guardia: modello sconosciuto, nessun prezzo da applicare.
        if (!pricing.Models.TryGetValue(model, out var price))
            return 0m;

        // 2) I prezzi sono per milione di token: scalo entrambi i lati.
        var input = inputTokens / 1_000_000m * price.InputPerMillionUsd;
        var output = outputTokens / 1_000_000m * price.OutputPerMillionUsd;
        return input + output;
    }
}
Codice completo nel repo: https://github.com/fscamuzzi/llm-observability-dotnet-opentelemetry

🚏 Passo 8 · Gli endpoint Minimal API

Due rotte: una bloccante e una in streaming SSE. Nella risposta bloccante restituisco anche il trace id, perché è il gesto che rende l'osservabilità utilizzabile davvero: incolli quell'id nella dashboard e sei sulla richiesta esatta di cui l'utente si sta lamentando, senza cercare per orario.

Il tool `GetOrderStatus` non serve alla demo funzionale, serve alla traccia: ogni sua esecuzione diventa uno span figlio, ed è così che si scopre che il collo di bottiglia non era il modello ma la query dietro il tool.

Endpoints/ChatEndpoints.cs · C#
// Chiamata bloccante: risponde con i numeri che finiscono anche in dashboard,
// più il trace id con cui ritrovare la richiesta.
private static async Task<IResult> HandleChatAsync(
    ChatRequest request,
    IChatClient chatClient,
    CostCalculator calculator,
    IOptions<PricingOptions> pricing,
    CancellationToken cancellationToken)
{
    // 1) Guardia: un prompt vuoto è un bug del client, non del modello.
    if (string.IsNullOrWhiteSpace(request.Prompt))
        return TypedResults.BadRequest(new ErrorResponse("Prompt must not be empty."));

    // 2) Interrogo il modello: tool e telemetria li gestisce la pipeline.
    var messages = BuildMessages(request.Prompt);
    var response = await chatClient.GetResponseAsync(
        messages,
        new ChatOptions { Tools = SupportTools.BuildTools() },
        cancellationToken);

    // 3) Ricalcolo lo stesso costo registrato dal middleware, per esporlo.
    var model = response.ModelId ?? "unknown";
    var input = response.Usage?.InputTokenCount ?? 0;
    var output = response.Usage?.OutputTokenCount ?? 0;
    var cost = calculator.Estimate(calculator.ResolveBillingModel(model), input, output);

    return TypedResults.Ok(new ChatReply(
        response.Text,
        model,
        input,
        output,
        cost,
        Activity.Current?.TraceId.ToString() ?? string.Empty));
}
Codice completo nel repo: https://github.com/fscamuzzi/llm-observability-dotnet-opentelemetry

▶️ Passo 9 · La prima richiesta

Con lo stack in piedi e l'API avviata, una curl basta a chiudere il cerchio. La risposta contiene modello, token, costo stimato e trace id: quattro numeri che prima non esistevano da nessuna parte.

Il costo qui è calcolato con il listino di gpt-4o-mini pur avendo risposto llama3.2:3b — 383 token in ingresso e 75 in uscita fanno 0,000102 dollari. Su una richiesta è rumore; su centomila è il numero che decide se la migrazione al cloud ha senso.

Terminale · POST /chat
curl -s http://localhost:5173/chat \
  -H 'content-type: application/json' \
  -d '{"prompt":"Qual è lo stato dell ordine A-1001?"}'

{
  "text": "Lo stato dell'ordine A-1001 è spedito, consegna prevista il 12 agosto 2026 con BRT.",
  "model": "llama3.2:3b",
  "inputTokens": 383,
  "outputTokens": 75,
  "estimatedCostUsd": 0.00010245,
  "traceId": "d6b5f062116d0af3fc41d7ac617dc81a"
}
Codice completo nel repo: https://github.com/fscamuzzi/llm-observability-dotnet-opentelemetry

📊 Passo 10 · Leggere la traccia

Nella dashboard, sotto Traces, la richiesta appare come un waterfall a quattro livelli: lo span HTTP, lo span `chat llama3.2:3b`, la prima chiamata al modello, l'esecuzione del tool e la seconda chiamata al modello dopo che il tool ha risposto.

È qui che le domande diventano risposte: due chiamate al modello per una sola richiesta spiegano i due secondi e mezzo, e il tool con i suoi dodici millisecondi si toglie subito dalla lista dei sospetti.

Il waterfall di una richiesta con function calling
Dettaglio di una traccia nella dashboard Aspire: lo span POST /chat da 2,6 secondi contiene lo span chat llama3.2:3b da 2,4 secondi, che a sua volta contiene una prima chiamata HTTP a ollama:11434 da 1 secondo, l'esecuzione del tool GetOrderStatus da 12 millisecondi e una seconda chiamata HTTP da 1,3 secondi; sotto, il pannello attributi mostra gen_ai.operation.name, gen_ai.request.model, i token in ingresso e uscita, il modello di fatturazione e il costo stimato in dollari.

Due giri di modello, un tool da 12 ms: il tempo è tutto nella generazione.

📈 Le tre metriche da tenere d'occhio

Sotto Metrics trovi gli istogrammi emessi dalla libreria più il contatore che ho aggiunto. Non servono venti pannelli: ne bastano tre, e ognuno risponde a una domanda diversa.

  • gen_ai.client.token.usage: quanto contesto stai bruciando. Un salto improvviso significa quasi sempre un prompt cresciuto o una cronologia mai potata.
  • gen_ai.client.operation.duration: guarda il p95, non la media. La distribuzione delle latenze di un LLM ha una coda lunga e la media la nasconde.
  • gen_ai.client.cost.usd: il totale, raggruppabile per modello. Con lo shadow pricing è la stima di spesa cloud calcolata sul traffico vero.
Token, latenza e costo affiancati
Pannello Metrics della dashboard Aspire con tre schede: a sinistra l'istogramma dei token per richiesta con le barre di input in verde e output in lime, al centro la curva della durata delle operazioni con p95 a 3,4 secondi e p50 a 1,1 secondi, a destra il contatore del costo stimato che segna 4,18 dollari su 3.104 richieste con billing.model gpt-4o-mini e request.model llama3.2:3b.

Tre pannelli, tre domande diverse: contesto, esperienza utente, spesa.

🔐 Prompt e risposte nelle tracce: quando sì

`EnableSensitiveData` mette il testo dei messaggi e delle risposte sugli span. In sviluppo è impagabile — vedi esattamente cosa è arrivato al modello — ma in produzione significa copiare in un sistema di telemetria tutto ciò che l'utente ha scritto, con quello che ne consegue in termini di dati personali e di retention.

La mia regola è semplice: acceso in locale via `appsettings.Development.json`, spento ovunque altro. Se in produzione serve davvero un campione, meglio un sampling esplicito su una quota minima di richieste che un interruttore globale.

appsettings.example.json · JSON
{
  "Ollama": {
    "Endpoint": "http://localhost:11434",
    "Model": "llama3.2:3b"
  },
  "Otel": {
    "ServiceName": "llm-observability-api",
    "Endpoint": "http://localhost:4317",
    "EnableSensitiveData": true
  },
  "Pricing": {
    "BillingModel": "gpt-4o-mini",
    "Models": {
      "gpt-4o-mini": { "InputPerMillionUsd": 0.15, "OutputPerMillionUsd": 0.60 }
    }
  }
}
Codice completo nel repo: https://github.com/fscamuzzi/llm-observability-dotnet-opentelemetry

🧪 Testare il contratto di telemetria

La strumentazione ha un difetto crudele: quando si rompe non lancia eccezioni, smette semplicemente di produrre dati. Un aggiornamento che rinomina una sorgente o cambia un attributo si scopre settimane dopo, davanti a un grafico vuoto.

Per questo tratto i nomi della telemetria come un contratto e li verifico con dei test: un `ActivityListener` sulla sorgente che l'app registra, un `MeterListener` sul contatore di costo. Nessun modello, nessuna rete — un provider finto che dichiara un uso noto e basta.

tests/TelemetryEmissionTests.cs · C#
[Fact]
public async Task Pipeline_EmitsAGenAiSpanOnTheConfiguredSource()
{
    // 1) Ascolto solo il nome che l'app registra con AddSource().
    var captured = new List<Activity>();
    using var listener = new ActivityListener
    {
        ShouldListenTo = source => source.Name == TelemetryNames.ChatSource,
        Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllDataAndRecorded,
        ActivityStopped = captured.Add,
    };
    ActivitySource.AddActivityListener(listener);

    // 2) Una chiamata attraverso la pipeline, su un provider finto.
    var (client, meter) = BuildPipeline(billingModel: "gpt-4o-mini");
    using var _ = meter;
    await client.GetResponseAsync("ciao");

    // 3) Lo span deve esistere e portare gli attributi delle semantic conventions.
    var span = Assert.Single(captured);
    Assert.Contains("gen_ai.usage.input_tokens", span.TagObjects.Select(t => t.Key));
    Assert.Contains("gen_ai.usage.output_tokens", span.TagObjects.Select(t => t.Key));

    // 4) …e il tag del mio middleware: prova che il costo gira DENTRO lo span.
    Assert.Contains("llmobs.usage.cost_usd", span.TagObjects.Select(t => t.Key));
}
Codice completo nel repo: https://github.com/fscamuzzi/llm-observability-dotnet-opentelemetry

⚠️ Gli errori che ho fatto (e che rifarei)

Quattro trappole che nessun messaggio di errore ti segnala, perché il risultato è sempre lo stesso: la dashboard sembra funzionare e semplicemente non mostra niente di utile.

  • Nome della sorgente disallineato: `UseOpenTelemetry()` senza `sourceName` e `AddSource()` con un nome inventato. Gli span esistono, nessuno li ascolta.
  • OpenTelemetry registrato per ultimo: finisce dentro il tool loop e lo span misura un giro solo. La latenza vera sparisce.
  • Streaming non prezzato: l'uso arriva in un `UsageContent` degli ultimi update, non nella risposta. Se non lo accumuli, metà del traffico risulta gratis.
  • service.name dimenticato: tutto finisce sotto «unknown_service» e i grafici di due servizi diversi si sommano senza che tu te ne accorga.

📦 GitHub repo

Tutto il codice di questo articolo — Minimal API strumentata, middleware di costo, test sul contratto di telemetria e `docker-compose.yml` con Ollama, il pull del modello e la dashboard Aspire — sta nel repo pubblico fscamuzzi/llm-observability-dotnet-opentelemetry. Cloni, `docker compose up -d`, `dotnet run` e in pochi minuti hai le tracce sotto gli occhi.

Nel README trovi i prerequisiti, la tabella delle tecnologie, cosa guardare nella dashboard e i comandi di pulizia. Ogni snippet di questo articolo è preso pari-pari da quel repo, che gira davvero: i sette test passano e la prima richiesta di esempio è quella che ho eseguito scrivendo l'articolo.

Terminale · provare il repo
git clone https://github.com/fscamuzzi/llm-observability-dotnet-opentelemetry
cd llm-observability-dotnet-opentelemetry

docker compose up -d
docker compose logs -f ollama-init   # aspetta il 'success'

dotnet test
dotnet run --project src/LlmObservability.Api

Repo GitHub · llm-observability-dotnet-opentelemetry

Codice completo nel repo: https://github.com/fscamuzzi/llm-observability-dotnet-opentelemetry

✅ Checklist finale

Prima di considerare strumentata un'app che chiama un LLM, questi sono i punti che verifico uno per uno.

  • `sourceName` esplicito in `UseOpenTelemetry()` e la stessa costante in `AddSource()` e `AddMeter()`?
  • `UseOpenTelemetry()` registrato per primo, quindi più esterno del tool loop?
  • `service.name` valorizzato con il nome vero del servizio?
  • Istrumentation di ASP.NET Core e HttpClient attive, per avere lo span radice e la chiamata al provider?
  • Il costo prezzato anche sulle chiamate in streaming?
  • `EnableSensitiveData` spento fuori dallo sviluppo?
  • Un test che fallisce se un nome della telemetria cambia?

Domande frequenti su osservabilità LLM .NET

Serve .NET Aspire per usare la dashboard Aspire?

No. La dashboard esiste come immagine Docker autonoma (mcr.microsoft.com/dotnet/aspire-dashboard) ed è a tutti gli effetti un endpoint OTLP con una UI sopra. Non serve un AppHost, non serve il pacchetto Aspire nel progetto: basta che la tua app esporti su OTLP. Vale anche per app non .NET.

Posso usare Grafana o Application Insights al posto della dashboard Aspire?

Sì, ed è il punto delle semantic conventions. L'app esporta su OTLP standard: cambi l'endpoint e la telemetria va in un OpenTelemetry Collector, in Grafana Tempo, in Application Insights o in Datadog senza toccare una riga di codice applicativo. La dashboard Aspire la uso in sviluppo perché è un container e zero configurazione.

Perché i token compaiono sullo span ma il costo no?

Perché il costo non è una proprietà della chiamata, è una proprietà del tuo contratto commerciale: la stessa risposta costa cifre diverse a seconda del provider e del piano. Le semantic conventions si fermano ai token; la conversione in denaro la aggiungi tu, ed è esattamente quello che fa il CostTrackingChatClient di questo articolo.

La strumentazione rallenta le chiamate al modello?

In modo trascurabile rispetto alla generazione. Uno span e qualche attributo si misurano in microsecondi, contro secondi di inferenza. L'unico costo reale è l'esportazione OTLP, che è comunque asincrona e batched. Il rischio vero non è la latenza ma il volume: con EnableSensitiveData acceso stai spedendo interi prompt a ogni richiesta.

Cosa cambia con un provider cloud invece di Ollama?

Nulla nella strumentazione: cambi solo lo strato più interno della pipeline, sostituendo OllamaApiClient con il client di OpenAI o di Azure OpenAI. Span, attributi, istogrammi e contatore di costo restano identici perché lavorano sull'astrazione IChatClient. Con un modello a pagamento, poi, lo shadow pricing diventa pricing e basta.

Parliamone

Se questo tema ti riguarda, scrivimi: confrontarsi su codice e AI è sempre tempo speso bene.