Span design: semantic tracing per thought, tool call, retrieval, generation
Un agent moderno è una macchina che pensa ad alta voce. Per rispondere a "trovami i tre articoli del portfolio piu rilevanti per RAG e Ollama, riassumili in italiano e proponimi tre titoli alternativi" puo decidere di chiamare un retriever, valutare se i risultati sono buoni, ri-formulare la query, chiamare di nuovo, ri-ordinare con un cross-encoder, generare il riassunto, valutare la qualita con un giudice LLM, e infine produrre i tre titoli. Trenta-cinquanta passaggi non sono un'eccezione: sono la norma quando inizi a montare reasoning loop, tool routing e self-criticism.
Se ognuno di questi passaggi finisce in un singolo log riga "DEBUG: agent step X done" sei nei guai gia al primo bug in produzione. Il debug diventa un esercizio di archeologia: leggi i log, ricostruisci a mente la timeline, indovini il parent-child, perdi due ore per scoprire che la rerank ha promosso un documento sbagliato perche l'embedding della query era stato troncato. Tracing distribuito non è un nice-to-have nel mondo LLM, è l'unico strumento che ti permette di vedere la struttura ad albero del reasoning.
Ma tracing fatto male è peggio di niente. Span senza convenzione di naming, attributi inventati ad hoc per ogni servizio, gerarchia che non rispecchia la causalita: ti ritrovi con dashboard inutilizzabili e query che non scalano. Questo articolo mostra come progettare uno span model semantico coerente, basato sulle OpenTelemetry GenAI semantic conventions, applicato al caso reale del mio ml-service portfolio dove ogni query RAG produce esattamente 4 span figli sotto un root request.
Cosa Imparerai
- Progettare una gerarchia di span semantici per pipeline RAG e agent multi-step
- Adottare le OpenTelemetry GenAI semantic conventions e estenderle con attributi custom
- Distinguere quando creare uno span figlio vs uno span event
- Costruire un attribute catalog standardizzato per cost, token, model, retrieval recall
- Propagare trace context e baggage cross-service tra Spring Boot e FastAPI
- Configurare sampling head e tail per controllare il costo storage Tempo
- Scrivere query TraceQL e dashboard Grafana per filtrare span per tool, modello, errore
Prerequisiti
- Lettura di EP02 (Langfuse) ed EP04 (Helicone) della serie
- Familiarita con OpenTelemetry SDK Python o Java
- Conoscenza base distributed tracing (concetto trace, span, parent-child)
- Esperienza con FastAPI o Spring Boot per il middleware tracing
- Stack Grafana Tempo o Jaeger gia disponibile per testare le query
Sezione 1 - Span semantics: 1 span = 1 unita di reasoning
La regola fondamentale che applico in produzione è brutalmente semplice: uno span rappresenta una singola unita di lavoro semanticamente significativa. Non un metodo, non una funzione, non una iterazione di loop: un atto cognitivo o operativo che ha senso isolato per chi legge il trace. Questo distingue tracing utile da tracing rumoroso.
Per un sistema LLM/agent identifico cinque famiglie di operazioni semanticamente rilevanti, da cui derivano cinque tipi di span con attributi diversi. Le cinque famiglie e i tipi corrispondenti sono il modello mentale che uso per ogni nuovo servizio AI. La tabella sotto è la mia mappa di riferimento operativa.
| Tipo span | Quando crearlo | Attributi tipici | Durata tipica | Esempio nome |
|---|---|---|---|---|
| request (root) | Inizio richiesta API/job | http.method, http.route, user.id, request.id | 100ms - 30s | http.server.request POST /chat |
| agent.step | Singolo step reasoning agent | agent.step.index, agent.thought.summary, agent.action | 200ms - 5s | agent.step.3 |
| tool.call | Invocazione tool/function | tool.name, tool.input.size, tool.output.size, tool.success | 10ms - 2s | tool.code_search |
| retrieval (rag.*) | Query vector store o re-rank | retrieval.query, retrieval.k, retrieval.recall, retrieval.scores | 5ms - 100ms | rag.retrieve / rag.rerank |
| generation (gen_ai.*) | Chiamata a LLM provider | gen_ai.system, gen_ai.request.model, gen_ai.usage.*, gen_ai.response.* | 100ms - 5s | gen_ai.chat / gen_ai.embeddings |
Nel ml-service portfolio una query RAG semplice usa quattro di questi cinque tipi: il root request creato dal middleware FastAPI, due retrieval (rag.retrieve + rag.rerank), una generation (gen_ai.chat verso Ollama). Una query agent invece esplode in molti agent.step ciascuno con i suoi tool.call figli. Il modello regge entrambi i casi senza adattamenti.
Una nota controintuitiva sulla granularita. La tentazione naturale quando inizi a instrumentare è creare uno span per ogni funzione Python, sull'idea che "piu visibilita = meglio". È sbagliato per due motivi. Primo, i tool di visualizzazione (Tempo, Jaeger UI) diventano illeggibili oltre 50-100 span per trace: l'occhio umano non riesce a estrarre struttura da un albero cosi denso. Secondo, ogni span ha un overhead di serializzazione, network e storage: 1000 span/secondo per istanza significa 86M span/giorno, che a 2KB/span fanno 170 GB/giorno solo per i trace. La buona granularita è "una unita semantica che faresti notare a un ingegnere se gli stessi spiegando il sistema": rag.retrieve si, ma _compute_cosine_similarity_batch() chiamata da retrieve no, va come attributo o span event al massimo.
Una seconda regola pratica: gli span devono avere durata significativa. Se uno span dura meno di 1ms in produzione, di solito non vale la pena tracciarlo come span separato. L'eccezione sono operazioni che vuoi contare anche se brevissime (es. cache lookup): in quel caso usi un metric counter parallelamente, non uno span. Nel ml-service ho fatto questa scelta esplicitamente per la cache embedding: cache hit incrementa il counter portfolio.cache.hit, cache miss crea lo span gen_ai.embeddings. Cosi il trace mostra solo le chiamate "vere" all'embedder, mentre il cache hit rate lo vedo nelle metriche Prometheus aggregate senza inquinare la struttura del trace.
Vediamo subito il setup base OpenTelemetry per FastAPI con queste convenzioni. Lo snippet sotto mostra l'inizializzazione del tracer e il pattern per creare span tipizzati con un helper che applica gli attributi standard. Il tracer punta a un Grafana Tempo locale via OTLP gRPC, che è la combinazione che uso in dev.
# ml-service/app/observability/tracing.py
# Setup OpenTelemetry tracing per FastAPI con convenzioni GenAI
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource
from contextlib import contextmanager
from typing import Iterator
# Resource: identifica il servizio nel backend Tempo
RESOURCE = Resource.create({
"service.name": "portfolio-ml-service",
"service.version": "1.4.2",
"deployment.environment": "production",
})
def init_tracer(otlp_endpoint: str = "http://tempo:4317") -> None:
"""Inizializza il tracer provider globale.
Da chiamare una volta sola al boot di FastAPI (lifespan startup).
OTLP gRPC è piu efficiente di HTTP per volumi alti.
"""
provider = TracerProvider(resource=RESOURCE)
exporter = OTLPSpanExporter(endpoint=otlp_endpoint, insecure=True)
# BatchSpanProcessor: accumula span e flush ogni 5s o 512 span
provider.add_span_processor(BatchSpanProcessor(exporter, max_export_batch_size=512))
trace.set_tracer_provider(provider)
tracer = trace.get_tracer("portfolio.ml")
@contextmanager
def gen_ai_span(operation: str, model: str, system: str = "ollama") -> Iterator[trace.Span]:
"""Helper per span GenAI con attributi standard pre-popolati.
Coerente con OpenTelemetry GenAI semantic conventions.
Operation values: chat, embeddings, text_completion.
"""
span_name = f"gen_ai.{operation}"
with tracer.start_as_current_span(span_name) as span:
# Attributi obbligatori per OTel GenAI conventions
span.set_attribute("gen_ai.system", system)
span.set_attribute("gen_ai.operation.name", operation)
span.set_attribute("gen_ai.request.model", model)
try:
yield span
except Exception as exc:
# Marca span come errore: visibile nei filtri TraceQL
span.set_status(trace.Status(trace.StatusCode.ERROR, str(exc)))
span.record_exception(exc)
raise
Tre dettagli critici. Primo, il BatchSpanProcessor con max_export_batch_size=512 previene di fare un round-trip per ogni span: in carico alto un esportatore sincrono uccide la latenza del servizio. Secondo, service.name nel Resource è il discriminator che Tempo usa per separare i trace di servizi diversi: senza, hai tutto in un mucchio. Terzo, l'helper gen_ai_span applica subito gli attributi standard: chi instrumenta nuovo codice non deve ricordarsi i nomi corretti, segue il pattern.
Sezione 2 - Naming convention OTel GenAI: rispetta lo standard, estendi con criterio
OpenTelemetry ha pubblicato a fine 2024 il primo draft delle GenAI semantic conventions e nel 2025 sono diventate stabili abbastanza per essere adottate. Il valore di seguirle è enorme: tool come Tempo, Jaeger, Honeycomb, Datadog hanno gia query template e dashboard pre-configurati che riconoscono quei nomi. Inventarsi un namespace proprietario significa precludersi questa interoperabilita.
La regola pratica: usa il namespace gen_ai.* per tutto quello che lo standard copre, aggiungi sotto-namespace tuoi (rag.*, agent.*, tool.*) per quello che non copre. Mai sovrascrivere semantica standard con valori custom. La tabella sotto è un estratto delle convenzioni che applico nel ml-service, separando lo standard dalle estensioni proprietarie.
| Categoria | Attribute key | Tipo | Standard OTel? | Esempio valore |
|---|---|---|---|---|
| System | gen_ai.system | string | Si | ollama |
| Model | gen_ai.request.model | string | Si | llama3.1:8b |
| Operation | gen_ai.operation.name | string | Si | chat |
| Tokens input | gen_ai.usage.input_tokens | int | Si | 1842 |
| Tokens output | gen_ai.usage.output_tokens | int | Si | 312 |
| Finish reason | gen_ai.response.finish_reasons | string[] | Si | ["stop"] |
| Temperature | gen_ai.request.temperature | double | Si | 0.2 |
| Cost stimato | portfolio.gen_ai.cost_eur_micro | int | No (custom) | 0 (Ollama on-prem) |
| Retrieval k | rag.retrieve.k | int | No (custom) | 20 |
| Retrieval recall | rag.retrieve.recall_at_5 | double | No (custom) | 0.85 |
| Tool name | tool.name | string | No (custom) | search_articles |
| Agent step | agent.step.index | int | No (custom) | 3 |
Tre note sull'estensione custom. Il prefix portfolio.* sui campi non standard chiarisce immediatamente la provenienza ed evita collisioni se un giorno OTel introducesse un attributo simile. Per il cost uso un intero in micro-euro (1 EUR = 1.000.000 micro) invece di float: trace storage e query backend gestiscono interi piu efficientemente e non hai problemi di rappresentazione binaria. Per rag.retrieve.recall_at_5 serve avere una ground truth a runtime, che ottengo solo per query con feedback utente loggato: per il resto non popolo l'attributo (omettere è meglio che inserire valori dummy che inquineranno le query aggregate).
Una considerazione sulla cardinalita degli attributi che molti sottovalutano. I backend di tracing indicizzano gli attributi per permettere query veloci, e ogni nuovo valore distinto è una entry nell'indice. Attributi a bassa cardinalita (es. gen_ai.system con valori "ollama"/"openai"/"anthropic") sono ottimi: indice piccolo, query veloci. Attributi ad alta cardinalita (es. user.id con migliaia di valori distinti) sono utili ma costosi. Attributi a cardinalita esplosiva (es. il prompt completo come stringa) sono catastrofici: rendono l'indice inutilizzabile. La regola pratica: se non sai stimare quante varianti distinte avra un attributo nei prossimi 12 mesi, mettilo come span event invece che come attribute. Gli span event non vengono indicizzati per default, sono visibili solo aprendo lo span singolo.
Un'altra trappola comune: gen_ai.usage.input_tokens e output_tokens sono integer, non sono "ad alta cardinalita" ma generano comunque un range continuo. La best practice per metriche Prometheus derivate è di non bucketizzarli automaticamente: lascia che span-to-metric processor li converta in histogram con bucket espliciti che hanno senso per il tuo dominio (es. 100, 500, 1000, 2000, 5000, 10000, 20000 token). Bucket auto-generati spesso producono distribuzioni illeggibili. Nel collector sotto useremo proprio questa configurazione esplicita.
Sezione 3 - Hierarchy parent-child: la struttura del reasoning
La gerarchia degli span deve riflettere la causalita logica, non l'organizzazione del codice. Se il metodo retrieve_and_rerank() internamente chiama retrieve() e rerank(), devi creare due span figli (uno per retrieve, uno per rerank), non uno span padre per il metodo wrapper. Il lettore del trace pensa per fasi semantiche, non per funzioni Python.
Lo snippet sotto mostra il pipeline RAG completo del ml-service portfolio con la gerarchia di span che produce. Il root span è creato dal middleware FastAPI auto-instrumentation, i quattro span figli sono creati esplicitamente uno per fase. Tutti gli attributi sono allineati alla tabella precedente.
# ml-service/app/services/rag_pipeline.py
# Pipeline RAG end-to-end con span hierarchy thought-tool-retrieval-generation
from opentelemetry import trace
from app.observability.tracing import tracer, gen_ai_span
from app.providers.ollama import OllamaProvider
from app.retrieval.redis_store import RediSearchStore
from app.retrieval.reranker import CrossEncoderReranker
class RagPipeline:
"""Pipeline RAG: embed query -> retrieve -> rerank -> generate.
Ogni fase è uno span figlio del root request span.
"""
def __init__(
self,
ollama: OllamaProvider,
store: RediSearchStore,
reranker: CrossEncoderReranker,
) -> None:
self.ollama = ollama
self.store = store
self.reranker = reranker
async def query(self, user_query: str, k: int = 20, top_n: int = 5) -> dict:
"""Esegue RAG pipeline e ritorna risposta + sources.
Produce 4 span figli sotto il root http.server.request:
1. gen_ai.embeddings - encoding query
2. rag.retrieve - HNSW lookup su RediSearch
3. rag.rerank - cross-encoder MS-MARCO sui top-K
4. gen_ai.chat - generation Ollama llama3.1:8b
"""
# Span 1: embedding della query (sentence-transformers, dim 384)
with gen_ai_span("embeddings", model="all-MiniLM-L6-v2") as span:
query_vector = await self.ollama.embed(user_query)
span.set_attribute("gen_ai.usage.input_tokens", len(user_query.split()))
span.set_attribute("gen_ai.response.model", "all-MiniLM-L6-v2")
# Span 2: retrieval HNSW su RediSearch (top-K candidati)
with tracer.start_as_current_span("rag.retrieve") as span:
span.set_attribute("rag.retrieve.k", k)
span.set_attribute("rag.retrieve.index", "articles_idx")
candidates = await self.store.search(query_vector, k=k)
span.set_attribute("rag.retrieve.hits", len(candidates))
# Score top-1 utile per detection bassa qualita retrieval
if candidates:
span.set_attribute("rag.retrieve.top_score", candidates[0].score)
# Span 3: rerank cross-encoder (riordina i candidati)
with tracer.start_as_current_span("rag.rerank") as span:
span.set_attribute("rag.rerank.model", "ms-marco-MiniLM-L-6-v2")
span.set_attribute("rag.rerank.input_size", len(candidates))
reranked = await self.reranker.rerank(user_query, candidates, top_n=top_n)
span.set_attribute("rag.rerank.output_size", len(reranked))
# Span 4: generation Ollama llama3.1:8b
prompt = self._build_prompt(user_query, reranked)
with gen_ai_span("chat", model="llama3.1:8b") as span:
span.set_attribute("gen_ai.request.temperature", 0.2)
span.set_attribute("gen_ai.request.max_tokens", 1024)
response = await self.ollama.chat(prompt)
span.set_attribute("gen_ai.usage.input_tokens", response.input_tokens)
span.set_attribute("gen_ai.usage.output_tokens", response.output_tokens)
span.set_attribute("gen_ai.response.finish_reasons", [response.finish_reason])
# Cost zero perche Ollama on-prem, ma traccio comunque per coerenza
span.set_attribute("portfolio.gen_ai.cost_eur_micro", 0)
return {"answer": response.text, "sources": reranked}
def _build_prompt(self, query: str, contexts: list) -> str:
"""Costruisce prompt RAG con contesto + query (omesso per brevita)."""
...
Nota la coerenza: ogni span ha attributi specifici alla sua fase, mai duplicati su tutti gli span. Se metti rag.retrieve.k anche sullo span generation crei rumore e rendi piu difficili le query aggregate. La regola è: l'attributo va sullo span dove l'evento accade. Per query aggregate cross-fase usi le metriche derivate da span (span-to-metric processor del collector OTel).
Il risultato visualizzato in Tempo è un albero dove vedi a colpo d'occhio dove il tempo si concentra. Nel mio caso la generation occupa il 70-80% del totale (latency Ollama llama3.1:8b 8b parametri su CPU VPS Hetzner CCX23), retrieval e rerank sono nella coda dell'1-5% del totale. Questa visibilita immediata è impossibile con il logging tradizionale.
Un trucco che ho imparato in produzione è di settare gli attributi prima della chiamata async, non dopo. Se la chiamata Ollama solleva un'eccezione, gli attributi gia settati restano nello span exportato e ti permettono di capire il contesto dell'errore (quale modello, quale temperature, quale prompt template). Se invece settassi gen_ai.request.model dopo await self.ollama.chat(), in caso di exception non avresti quel dato. Questa abitudine fa la differenza tra un errore diagnosticabile in 2 minuti e un errore che richiede di riprodurre il problema localmente.
Un altro pattern utile è la pre-creazione condizionale degli span. Per esempio nel ramo agent: se l'agent decide di non fare retrieval (la query non richiede contesto esterno), non creo lo span rag.retrieve: il trace mostra solo gen_ai.chat sotto agent.step.0. Questo è importante perché uno span vuoto (zero work, zero attributi rilevanti) inquina le statistiche aggregate. Tempo conta tutti gli span anche quelli "skip", e se il 40% delle query non fa retrieval, il p50 della latency rag.retrieve sarebbe falsato verso lo zero. Meglio non creare lo span e avere statistiche pulite sui veri retrieval.
Sezione 4 - Span events per token streaming e annotation
Gli span events sono il complemento naturale degli attributi: punti-evento istantanei timestampati che annotano lo span padre senza creare un nuovo span figlio. Casi d'uso classici nel mondo LLM: ricezione di chunk durante token streaming, retry attempt, cache hit/miss, validation gate triggered.
La distinzione operativa che applico: se l'operazione ha durata propria misurabile e voglio aggregarla in metriche separate (es. quanto tempo si spende in totale in retry), allora è uno span figlio. Se è un evento atomico che annota lo span padre (es. è arrivato il primo token alle ore X), allora è uno span event. Lo snippet sotto mostra il pattern per il token streaming Ollama: lo span gen_ai.chat emette un event per chunk, permettendo di calcolare time-to-first-token come delta.
# ml-service/app/providers/ollama_streaming.py
# Span events per tracciare token streaming chunk-by-chunk
from opentelemetry import trace
from app.observability.tracing import gen_ai_span
import time
from typing import AsyncIterator
async def chat_stream_with_tracing(
ollama_client,
model: str,
prompt: str,
) -> AsyncIterator[str]:
"""Streaming chat Ollama con span events per ogni chunk.
Permette di calcolare time-to-first-token e inter-token-latency
senza creare uno span per chunk (esploderebbe il trace).
"""
with gen_ai_span("chat", model=model) as span:
span.set_attribute("gen_ai.request.streaming", True)
chunk_index = 0
first_chunk_time: float | None = None
start_time = time.perf_counter()
total_output_tokens = 0
async for chunk in ollama_client.chat_stream(model=model, prompt=prompt):
now = time.perf_counter()
if first_chunk_time is None:
first_chunk_time = now
# Event speciale: time-to-first-token
ttft_ms = int((now - start_time) * 1000)
span.add_event(
"gen_ai.content.completion.first_chunk",
attributes={
"gen_ai.completion.ttft_ms": ttft_ms,
},
)
# Event per ogni chunk: timestamp + indice + size
span.add_event(
"gen_ai.content.completion.chunk",
attributes={
"gen_ai.completion.chunk_index": chunk_index,
"gen_ai.completion.chunk_size_chars": len(chunk.text),
},
)
chunk_index += 1
total_output_tokens += chunk.token_count
yield chunk.text
# Attributi finali aggregati sullo span
total_ms = int((time.perf_counter() - start_time) * 1000)
span.set_attribute("gen_ai.usage.output_tokens", total_output_tokens)
span.set_attribute("gen_ai.completion.total_chunks", chunk_index)
span.set_attribute("gen_ai.completion.total_ms", total_ms)
if total_output_tokens > 0:
tps = (total_output_tokens / total_ms) * 1000
span.set_attribute("gen_ai.completion.tokens_per_second", round(tps, 2))
Il vantaggio di questo pattern è duplice. Primo, hai visibilita micro sul comportamento dello streaming senza esplodere il numero di span (uno span per chunk significherebbe migliaia di span per query lunga). Secondo, il TTFT è una metrica chiave UX per chat applications: differenza tra "risposta in 200ms" e "wait di 3s prima del primo token" è enorme per la user perception, anche se la latency totale è identica.
Tempo permette di filtrare span per evento name (es. { event.name="gen_ai.content.completion.chunk" }) e di estrarre metriche aggregate via TraceQL. Una metrica derivata utile è la distribuzione dei TTFT per modello, che ti permette di confrontare Ollama llama3.1:8b vs llama3.1:70b vs un eventuale provider cloud.
Tre considerazioni operative su questo pattern che ho affinato in produzione. Primo, gli span event hanno un overhead minore degli span figli (no creazione context separato, no parent-child link), ma sono comunque serializzati come parte dello span padre: con 1000 token streaming hai 1000 event in un singolo span, che diventa pesante. La mia regola: per chunk < 50 mando event individuali, sopra batch in gruppi di 10 (un event ogni 10 chunk con il chunk_count nel payload). Secondo, il TTFT puo essere ingannevole se misurato dal client: include la network latency. Per misurare il TTFT "vero" del modello uso la differenza tra span start (subito prima della call HTTP a Ollama) e primo event (appena ricevuto il primo byte). Terzo, gli event hanno un timestamp con precisione nanosecondi: è overkill per la maggior parte dei casi LLM (la granularita utile è millisecondi), ma è utile in profili a bassa latenza dove ogni microsecondo conta.
Un caso d'uso meno ovvio per gli span event è la registrazione delle decisioni. Se l'agent decide "salto la retrieval perche la query è una semplice greeting", emetto uno span event agent.decision.skip_retrieval con attributo reason="greeting_detected". Cosi nel trace vedi non solo cosa l'agent ha fatto, ma anche cosa ha deciso di non fare e perche. Questo livello di trasparenza è fondamentale per debuggare comportamenti emergenti dei modelli reasoning, dove spesso il bug è "ha saltato uno step che doveva fare".
Sezione 5 - Baggage propagation cross-service: portarsi dietro user_id e tenant_id
OpenTelemetry distingue due meccanismi di propagazione cross-service: trace context (header traceparent e tracestate, W3C standard) che porta trace_id e span_id, e baggage (header baggage) che porta key-value arbitrari attraverso il chiamante e tutti i suoi figli. Il primo è indispensabile (senza, perdi la causalita cross-service), il secondo è una feature potente ma sotto-utilizzata.
Nel portfolio uso baggage per propagare quattro metadati dal Spring Boot backend al ml-service FastAPI: user.id, tenant.id (per multi-tenancy futuro), auth.role (free/premium/admin), feature_flag.experimental_rerank (opt-in per A/B test). Questi metadati arrivano automaticamente in ogni span del ml-service senza dover ri-iniettare manualmente. Lo snippet sotto mostra setup lato Spring Boot (writer) e lato FastAPI (reader).
# ml-service/app/observability/baggage_middleware.py
# Lettura baggage propagato da Spring Boot e arricchimento span automatico
from opentelemetry import baggage, trace
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
class BaggageEnrichmentMiddleware(BaseHTTPMiddleware):
"""Estrae baggage W3C dal request e popola span attributi.
Spring Boot upstream popola: user.id, tenant.id, auth.role, feature_flag.*
Cosi ogni span del ml-service ha gia questi attributi senza propagazione manuale.
"""
async def dispatch(self, request: Request, call_next) -> Response:
# Il context propagator FastAPI ha gia letto traceparent + baggage
# Ora copio i valori utili come attribute sullo span corrente
current_span = trace.get_current_span()
if current_span and current_span.is_recording():
for key in ("user.id", "tenant.id", "auth.role"):
value = baggage.get_baggage(key)
if value is not None:
current_span.set_attribute(key, value)
# Feature flag per A/B test: tutti i flag con prefix feature_flag.*
for key, value in baggage.get_all().items():
if key.startswith("feature_flag."):
current_span.set_attribute(key, value)
response = await call_next(request)
return response
Il pattern è "set once, query everywhere". Una volta che user.id è baggage, ogni singolo span downstream lo ha come attribute. Vuoi vedere tutti i trace di un utente specifico? Query TraceQL { resource.service.name="portfolio-ml-service" && .user.id="42" } e vedi tutto, dall'auth fino alla generation Ollama. Vuoi confrontare error rate tra utenti free e premium? Group by auth.role. Vuoi misurare effetto del feature flag sperimentale? Filtri su feature_flag.experimental_rerank=true e confronti latency e success rate.
Attenzione a un errore comune: non mettere tutto in baggage. Il baggage viaggia in ogni HTTP request cross-service e impatta header size. Limitati a metadati con cardinalita controllata (user.id si, request body completo no) e bytes contenuti (string brevi). Per dati grandi usi attributi locali allo span dove servono.
Il side-effect piu insidioso che ho incontrato con baggage in produzione è la propagation a sistemi terzi non desiderati. Se il ml-service chiama un'API esterna (es. webhook utente, notification service) e il client HTTP propaga automaticamente baggage, stai leakando user_id e tenant_id a sistemi terzi che non dovrebbero vederli. Per evitare il problema configuro un BaggagePropagator custom che permette propagation solo verso host whitelisted (Spring Boot e Tempo collector). Ogni altro target (call HTTP esterna) viene strippato del baggage prima dell'invio. Questo è particolarmente importante per compliance GDPR: la user_id che esce dal tuo perimetro tecnico va trattata come export di dato personale.
Un secondo pattern utile è usare baggage per marker di debug ad hoc. Se un utente segnala un bug, posso temporaneamente aggiungere debug.verbose=true al baggage dei suoi request (via header X-Debug-Token risolto al backend Spring), e tutti i miei middleware controllano questo flag per emettere span event extra (prompt completo, retrieval scores dettagliati, intermediate reasoning). Cosi raccolgo trace di debug solo per quell'utente specifico per qualche ora, senza alzare il verbosity globale che farebbe esplodere lo storage. Una volta diagnosticato il bug, rimuovo il marker.
Sezione 6 - Sampling strategy: head, tail, attribute-based
A volume alto (sopra 100K trace/giorno) lo storage trace diventa un costo non banale. Tempo con backend S3-compatible è efficiente ma 100% di sampling su 1M trace/giorno significa decine di GB/giorno e query lente. Lo strumento di mitigation è il sampling, ma fatto male perdi proprio i trace interessanti (quelli con errore o latency anomala).
Tre approcci complementari nel ml-service portfolio. Head sampling (decisione presa al boot del trace, semplice e veloce): probabilistico al 100% finche è sostenibile. Tail sampling (decisione presa a fine trace, dopo aver visto tutti gli span): regole intelligenti basate su outcome. Attribute-based sampling (sub-caso di tail): tieni il 100% se errore o se latency > soglia, altrimenti campiona al 5%.
Lo snippet sotto mostra la configurazione OpenTelemetry Collector per il tail sampling che uso in produzione, con quattro policy combinate. Il collector riceve gli span da FastAPI via OTLP, li bufferizza per 30 secondi (tempo per ricevere tutti gli span del trace), poi decide se forwardare a Tempo.
# infra/otel-collector/config.yaml
# OpenTelemetry Collector con tail sampling intelligente
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
processors:
# Tail sampling: decide dopo aver visto tutto il trace
tail_sampling:
# Aspetta 30s che tutti gli span del trace arrivino prima di decidere
decision_wait: 30s
num_traces: 50000
expected_new_traces_per_sec: 100
policies:
# Policy 1: tieni 100% se trace ha almeno uno span con errore
- name: errors-policy
type: status_code
status_code:
status_codes: [ERROR]
# Policy 2: tieni 100% se latency totale > 5s (slow trace)
- name: slow-policy
type: latency
latency:
threshold_ms: 5000
# Policy 3: tieni 100% se cost-per-query > 1 EUR (cost anomaly)
- name: expensive-policy
type: numeric_attribute
numeric_attribute:
key: portfolio.gen_ai.cost_eur_micro
min_value: 1000000 # 1 EUR in micro
# Policy 4: probabilistic 5% del traffico restante
- name: random-sample-policy
type: probabilistic
probabilistic:
sampling_percentage: 5
# Genera metriche aggregate da TUTTI gli span (anche non campionati)
spanmetrics:
metrics_exporter: prometheus
dimensions:
- name: gen_ai.system
- name: gen_ai.request.model
- name: tool.name
exporters:
otlp/tempo:
endpoint: tempo:4317
tls:
insecure: true
prometheus:
endpoint: 0.0.0.0:8889
service:
pipelines:
traces:
receivers: [otlp]
processors: [spanmetrics, tail_sampling]
exporters: [otlp/tempo]
metrics:
receivers: [otlp]
exporters: [prometheus]
Tre dettagli operativi importanti. Il decision_wait: 30s è un trade-off: troppo basso e perdi span che arrivano in ritardo, troppo alto e tieni in memoria troppi trace. 30s copre il 99% dei casi nel ml-service (la query RAG piu lunga è < 10s). Il spanmetrics processor genera metriche Prometheus aggregate prima del sampling: cosi anche per i trace non campionati ricavi count, latency p50/p95/p99 per modello e tool. La policy 3 sul cost: il valore in micro-EUR (1 EUR = 1.000.000) ti permette di filtrare numericamente senza problemi di rappresentazione float.
Attenzione: il tail sampling richiede che tutti gli span dello stesso trace arrivino allo stesso collector instance. In setup multi-collector serve un load balancer trace-aware o un primo stage di routing per trace_id. Per setup single-node (mio caso VPS Hetzner) non è un problema.
Una variante piu sofisticata di tail sampling che ho sperimentato è il cohort sampling: invece di campionare random il 5%, campiono il 100% per gruppi specifici (es. il 100% dei trace dei nuovi utenti registrati nelle ultime 24 ore, il 100% dei trace per cui è attivo un feature flag sperimentale, il 100% dei trace di utenti con tier premium). Questo si implementa con una policy aggiuntiva basata su attribute filter (type: string_attribute, key: user.tier, values: ["premium"]). Il vantaggio è che hai osservabilita totale per le coorti che ti interessano e statistiche aggregate per il resto. Il rovescio è lo storage: se il 20% dei tuoi utenti è in cohort speciali, mantieni il 20% del traffico totale, non il 5%.
Un caveat importante sul costo storage. Tempo con object storage S3-compatible (MinIO sul mio VPS) costa molto meno di Tempo con block storage locale, ma le query sono piu lente perche devono scaricare i blocchi da S3 prima di filtrarli. Per il ml-service portfolio con <100K trace/giorno la latenza query è accettabile (5-15 secondi per query complesse su 7 giorni di dati). Sopra 1M trace/giorno conviene investire in caching layer (Memcached davanti a Tempo) o migrare a backend ClickHouse che è colonnare e indicizza meglio gli attributi. La regola: misura sempre la latenza p95 delle tue query Tempo nelle dashboard reali, non in benchmark sintetici.
Sezione 7 - TraceQL queries e dashboard Grafana Tempo
Il valore degli span instrumentati con cura emerge solo quando puoi interrogarli efficacemente. Tempo offre TraceQL, un query language simile a PromQL ma per trace. Sintassi base: { filtro_span } | operatore. Vediamo le query che uso quotidianamente nelle dashboard ml-service.
# Query TraceQL salvate nelle dashboard Grafana Tempo del ml-service portfolio
# Query 1: tutti i trace con un tool.* fallito (debug pattern errore)
{ name=~"tool\\..*" && status=error }
# Query 2: trace di chat Ollama con generation > 3s (slow generation detection)
{ name="gen_ai.chat" && .gen_ai.request.model="llama3.1:8b" && duration > 3s }
# Query 3: tutti i trace di un utente specifico (debug supporto)
{ resource.service.name="portfolio-ml-service" && .user.id="42" }
# Query 4: retrieval con top-score basso (qualita retrieval issue)
{ name="rag.retrieve" && .rag.retrieve.top_score < 0.5 }
# Query 5: trace cross-service (Spring Boot + ml-service) con totale > 5s
{ resource.service.name="portfolio-backend" } && { resource.service.name="portfolio-ml-service" } | trace { duration > 5s }
# Query 6: feature flag A/B test, latency comparison
{ .feature_flag.experimental_rerank="true" && name="gen_ai.chat" }
# Query 7: span events streaming first-chunk-time alto (TTFT lento)
{ name="gen_ai.chat" && event.name="gen_ai.content.completion.first_chunk" && event.attributes["gen_ai.completion.ttft_ms"] > 2000 }
Queste sette query sono la base della mia dashboard "ML Service Health" su Grafana. Sono panel separati: panel 1 è il count delle query 1 e 2 nelle ultime 6 ore (errori e slow), con threshold di alert se passano di 5 errori/ora o 20 slow/ora. Panel 2 mostra l'istogramma della query 4 (top-score retrieval) per trend qualita: se la distribuzione si sposta a sinistra significa che il dataset indicizzato è diventato meno coerente con le query reali. Panel 3 è il TTFT trend dalla query 7, baseline 800-1200ms, alert se sale sopra 2000ms.
Lo snippet finale è la specifica JSON di un panel Grafana che usa una query TraceQL come datasource. È il pannello "RAG Retrieval Quality" del mio dashboard, che mostra distribuzione del top-score della retrieval su finestra mobile 24h. È il template che riuso per tutti i pannelli quality-related.
{
"title": "RAG Retrieval Quality - top_score distribution (24h)",
"type": "histogram",
"datasource": {
"type": "tempo",
"uid": "tempo-portfolio"
},
"targets": [
{
"queryType": "traceql",
"query": "{ name=\"rag.retrieve\" && resource.service.name=\"portfolio-ml-service\" }",
"limit": 1000,
"refId": "A",
"tableType": "spans"
}
],
"fieldConfig": {
"defaults": {
"custom": {
"fillOpacity": 80,
"gradientMode": "none"
},
"thresholds": {
"mode": "absolute",
"steps": [
{"color": "red", "value": null},
{"color": "yellow", "value": 0.5},
{"color": "green", "value": 0.7}
]
}
}
},
"options": {
"bucketSize": 0.05,
"bucketOffset": 0,
"combine": true,
"legend": {
"displayMode": "table",
"placement": "right",
"showLegend": true,
"calcs": ["count", "mean", "min", "max"]
}
},
"transformations": [
{
"id": "extractFields",
"options": {
"source": "spanAttributes",
"fields": [
{"name": "rag.retrieve.top_score", "type": "number"}
]
}
}
],
"alert": {
"conditions": [
{
"evaluator": {
"type": "lt",
"params": [0.5]
},
"operator": {"type": "and"},
"query": {
"params": ["A", "1h", "now"],
"datasourceId": 1
},
"reducer": {
"type": "avg",
"params": []
}
}
],
"executionErrorState": "alerting",
"for": "15m",
"frequency": "5m",
"name": "RAG retrieval quality degraded"
}
}
L'alert sotto-pannel si attiva quando l'avg del top-score scende sotto 0.5 per piu di 15 minuti consecutivi. È un signal di degrado qualita: o il corpus indicizzato è diventato obsoleto, o il pattern delle query utente è cambiato e l'embedding non lo cattura piu bene. Grazie a questo alert ho scoperto che dopo l'aggiunta di 50 nuovi articoli IT non re-indicizzati il recall era crollato del 30%, perche il re-indexing automatico al boot non era stato triggherato dopo deploy delle migration nuove.
Una nota finale sulla composizione delle dashboard. Non costruire una sola dashboard "tutto incluso" con 30 pannelli: è inutilizzabile, nessuno la guarda. Costruisci dashboard tematiche da 5-8 pannelli ciascuna, organizzate per ruolo: una "ML Service Health" per on-call (errori, latency, alert attivi), una "RAG Quality" per data scientist (recall, top-score, embedding drift), una "Cost & Capacity" per FinOps (token usage per modello, cost trend, capacity headroom), una "User Experience" per product manager (TTFT, end-to-end latency, success rate per feature). Ogni dashboard ha un proprio time range default e auto-refresh diverso (5s per on-call, 1m per quality, 5m per cost). Questa segmentazione è la differenza tra "abbiamo dashboard" e "le dashboard vengono usate".
Un altro pattern che funziona bene è di linkare le dashboard tra loro via Grafana variables. Per esempio, dalla dashboard "ML Service Health" se clicchi su uno spike di errori, vai automaticamente alla dashboard "Trace Explorer" con il filtro time-range gia impostato sull'intervallo dello spike e status=error gia attivo. Questo accorcia drasticamente il time-to-diagnosis durante un incident: nessun bisogno di copiare time range, query manuali, navigation a manuale. Tutto è un click. Nel mio setup ho misurato il MTTR (Mean Time To Resolution) prima e dopo questa ottimizzazione: è sceso da una media di 28 minuti a 11 minuti per incident di gravita medium.
FAQ
Quale convenzione di naming devo usare per gli span LLM in OpenTelemetry?
Usa le OpenTelemetry GenAI semantic conventions (gen_ai.* namespace) come baseline e aggiungi un sotto-namespace tuo per la parte custom. Naming pattern consigliato: gen_ai.{operation} per le operazioni standard (chat, embeddings, text_completion) e {dominio}.{azione} per quelle proprietarie (rag.retrieve, rag.rerank, agent.step, tool.code_search). Evita nomi generici come llm_call o process: non sono filtrabili in dashboard. Nel ml-service portfolio uso esattamente questa combinazione, con prefix gen_ai.* per le chiamate Ollama e rag.* per le fasi del pipeline retrieval. Cosi un singolo filtro su nome span span.name=~"rag.*" mi mostra tutto il pipeline RAG, mentre gen_ai.operation.name="embeddings" isola le chiamate al modello sentence-transformers.
Quanti span devo creare per una query RAG end-to-end?
Il modello canonico nel ml-service portfolio è 4 span figli sotto il root request: (1) gen_ai.embeddings per l'encoding della query con all-MiniLM-L6-v2 (latency tipica 8-15 ms), (2) rag.retrieve per la query RediSearch HNSW (5-20 ms), (3) rag.rerank per il cross-encoder MS-MARCO che riordina i top-K candidati (30-80 ms), (4) gen_ai.chat per la generation con Ollama llama3.1:8b (300-2000 ms). Sopra ho anche un root span http.server.request creato dal middleware FastAPI che misura latency totale. Andare oltre 4-6 span per query RAG semplice è over-instrumentation: aggiungi rumore al trace senza valore diagnostico. Per agent multi-step invece è giustificato avere 1 span per step (agent.step.0..N) ciascuno con i suoi tool.call figli.
Devo loggare il prompt completo come span attribute o solo metadati?
Dipende da volume, privacy e regolamento. Nel ml-service portfolio loggo solo gen_ai.prompt.tokens (count) e gen_ai.prompt.template_id (riferimento al template salvato a parte) come attributi standard, mentre il body completo del prompt finisce in uno span event nominato gen_ai.content.prompt con un attribute gen_ai.prompt.text troncato a 2000 caratteri. Questo perché: (1) gli attributi sono sempre serializzati nel trace anche con sampling, mentre gli event sono opzionali e disattivabili a runtime; (2) prompt completi possono contenere PII e devi poterli scrubbing senza alterare la cardinalita degli attributi; (3) Tempo e Jaeger hanno limiti pratici sulla dimensione dello span (consigliato sotto 64KB), quindi prompt lunghi vanno troncati o esternalizzati in object storage con riferimento.
Come gestisco la propagazione del trace context tra Spring Boot e ml-service FastAPI?
Usi il W3C Trace Context standard via header HTTP traceparent e tracestate. Spring Boot con Micrometer Tracing OpenTelemetry bridge li popola automaticamente sulle outgoing HTTP request via WebClient. FastAPI dal lato ricevente li legge con opentelemetry-instrumentation-fastapi che estrae il context dall'header traceparent e crea uno span figlio. Per metadata non standard (es. user_id, tenant_id, feature_flag) usi OpenTelemetry baggage: è un secondo header (baggage) propagato cross-service, leggibile in ogni span come baggage attribute. Nel portfolio passo cosi user.id e auth.role dal Spring backend al ml-service per poter filtrare il trace per utente senza dover ri-iniettare manualmente in ogni chiamata.
Quale sampling strategy adotto per non far esplodere il costo storage di Tempo?
Tre approcci complementari nel ml-service portfolio: (1) head sampling probabilistico al 100% per ora, fino a 50K trace/giorno è sostenibile su Tempo self-hosted con storage S3-compatible (in produzione MinIO sul VPS); (2) tail sampling via OpenTelemetry collector quando passero a 500K+ trace/giorno: regola "sample 100% se errore, 100% se latency > p99, 5% del traffico normale"; (3) attributi sempre presenti per ricostruire metriche aggregate anche su trace non campionati, via span-to-metric processor del collector. Cosi tieni i trace dettagliati per casi interessanti e ricavi statistiche su tutto il volume. Sopra 1M trace/giorno valuta ClickHouse come storage backend per Tempo, query 5-10x piu veloci su large dataset.
Span event vs nuovo span figlio: quando uso quale?
Regola pratica: se l'operazione ha durata propria misurabile e potresti volerla aggregare in metriche separate, è uno span figlio. Se è un punto-evento istantaneo che annota lo span padre (es. token streaming chunk ricevuto, retry attempt, cache hit) è uno span event. Esempi concreti dal ml-service: la chiamata a Ollama è uno span gen_ai.chat (ha durata, attributi propri, va in metriche aggregate per modello). Ogni token streaming chunk durante quella chiamata è uno span event nominato gen_ai.content.completion.chunk con attribute gen_ai.completion.chunk_index e timestamp. Questo modello è coerente con OTel GenAI conventions e ti permette di calcolare time-to-first-token come delta tra span start e primo event.
Come trovo in Grafana Tempo i trace dove un tool call ha fallito?
TraceQL è il query language di Tempo, simile a PromQL ma per trace. Query base: { name=~"tool\\..*" && status=error } restituisce tutti gli span tool.* con status error. Per filtrare solo tool falliti che hanno causato fallimento del trace intero: { name=~"tool\\..*" && status=error } | select(trace:rootName, trace:duration). Per pattern complessi (es. tool fallito seguito da retry riuscito): usi span event filtering con event.name="retry.success". Le query si salvano in dashboard Grafana come variabili e produci report tipo "top 10 tool con error rate piu alto". Nel portfolio ho una dashboard fissa con 4 panel: tool error rate per tool name, latency p50/p95/p99 per tool, cost per query trend, retrieval recall metric da span attribute.
Conclusione
Il salto di qualita nell'osservabilita LLM non è "aggiungere logging strutturato": è adottare un modello a span semantici dove ogni unita di reasoning ha la sua identita, attributi standardizzati e posizione precisa nella gerarchia parent-child. Le OpenTelemetry GenAI semantic conventions ti danno il vocabolario condiviso, l'estensione con sotto-namespace custom (rag.*, agent.*, tool.*) ti permette di coprire la specificita del tuo dominio senza rompere l'interoperabilita con dashboard e tool standard.
Il caso del ml-service portfolio dimostra che il modello regge sia per pipeline RAG semplici (4 span sotto un root request) sia per agent multi-step (decine di span agent.step con figli tool.call). Baggage propagation porta user_id e feature flag cross-service senza re-iniezione manuale, sampling tail-based mantiene il costo storage controllato preservando i trace interessanti, e le query TraceQL su Grafana Tempo trasformano lo span model in dashboard operative con alert efficaci. Il ROI è immediato: bug diagnosticati in 5 minuti invece di 2 ore, degrado qualita rilevato automaticamente prima che gli utenti reclamino.
Il prossimo passo è instrumentare le librerie agent (LangChain, LlamaIndex) usando le stesse convenzioni: ogni chain step diventa uno span agent.step, ogni tool dichiarato diventa uno span tool.{name}, ogni LLM call mantiene il suo gen_ai.chat. Questo è il tema dell'episodio successivo, dove vedremo come configurare le auto-instrumentation OTel per LangChain e LlamaIndex senza dover scrivere span manuali per ogni chain.
Prossimo episodio: OpenTelemetry GenAI semantic conventions: instrumentation auto per LangChain e LlamaIndex, attribute mapping completo, alert su agent step depth.

Commenti
Caricamento commenti...
Accedi per commentare