End-to-end workflow LLM observability: instrument agent, production deploy, cost SLA
Tutta la Serie 27 fin qui ha smontato i pezzi singoli: silent failures (EP01), Langfuse (EP02), Phoenix (EP03), Helicone (EP04), span design (EP05), cost tracking (EP06), alerting (EP07), debug trace (EP08). In questo episodio finale li ricomponiamo in un unico workflow operativo: prendi un agent LangChain in sviluppo, lo instrumenti con Langfuse + OpenTelemetry, lo deployi in produzione con una checklist di 30 item, lo metti dietro un cost SLA con circuit breaker e lo monitori con Grafana, smoke test e report mensile per il CFO.
Il filo conduttore non è uno strumento o una libreria, è la continuita: la stessa instrumentation che usi in dev gira identica in prod, le stesse metriche emettono dati per il debug ad-hoc e per il report finance, lo stesso agent si rifiuta di rispondere quando supera il budget invece di silenziosamente bruciare i tuoi soldi. Tutto applicato al mio ml-service portfolio reale (FastAPI + Ollama on-prem su VPS Hetzner), con numeri concreti: 12.4K request/mese, €23.40 cost totale, p99 latency 1.8s, top user 8% del cost.
Questo articolo è anche la chiusura della Serie 27. In coda trovi una ricapitolazione completa dei 9 episodi e i link a tre serie correlate (22, 23, 33) per estendere lo stack observability verso self-hosted LLM, cost optimization e governance.
Cosa Imparerai
- Instrumentare un agent LangChain end-to-end con Langfuse 2.x + OpenTelemetry GenAI semantic conventions
- Strutturare una deployment checklist YAML 30-item con 5 no-go criteria critici
- Configurare cost SLA con soft-cap (80%), hard-cap (100%) e circuit breaker via Redis
- Importare dashboard Grafana JSON con 6 panel essenziali per review giornaliera 5 minuti
- Implementare alert rule Python per SLA breach con baseline rolling 7 giorni
- Costruire smoke test framework post-deploy con 5 assertion e rollback automatico
- Generare CFO cost report mensile PDF + spreadsheet con narrative finance-friendly
Prerequisiti
- Lettura degli episodi EP01-EP08 della Serie 27 per il contesto observability completo
- Esperienza pratica con LangChain o framework agent equivalente (LlamaIndex, Haystack)
- Familiarita con OpenTelemetry SDK Python e Prometheus exporter
- Conoscenza systemd unit, ExecStartPost e deploy manuale su VPS Linux
- Comprensione concetti SLA, SLO, error budget e circuit breaker
Sezione 1 - Instrument LangChain agent con OTel + Langfuse
Lo strato base è OpenTelemetry con le GenAI semantic conventions (gen_ai.system, gen_ai.request.model, gen_ai.usage.input_tokens, gen_ai.usage.output_tokens), standard CNCF in adozione 2025-2026. Sopra ci appoggio il SDK Langfuse per le feature LLM-specifiche che OTel non copre (prompt versioning, evaluation dataset, dataset comparison). I due livelli convivono via OTLP exporter: Langfuse 2.x consuma span OTel nativamente, quindi un solo processor span genera dati per Langfuse e per qualsiasi altro backend OTel-compatible (Tempo, Jaeger, Datadog).
Lo snippet sotto è lo scheletro di instrumentation che gira nel mio ml-service. Importante: il FastAPIInstrumentor aggancia automaticamente ogni endpoint creando il root span, mentre il LangChainInstrumentor intercetta ogni chain step, ogni tool call e ogni LLM call generando span con attributi GenAI standard. Il custom processor aggiunge metadata business (user_id, tier, feature_flag) che mi servono per cost chargeback e A/B testing.
# ml-service/app/observability/instrumentation.py
"""
Stack instrumentation end-to-end:
- OTel SDK con GenAI semantic conventions
- Langfuse 2.x come destinazione tramite OTLP exporter
- FastAPI auto-instrumentation per HTTP root span
- LangChain auto-instrumentation per agent step
- Custom span processor per metadata business (user_id, tier)
"""
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider, SpanProcessor
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.langchain import LangChainInstrumentor
from opentelemetry.sdk.resources import Resource
from contextvars import ContextVar
from langfuse import Langfuse
# Context per propagare metadata business cross-span
current_user_ctx: ContextVar[dict] = ContextVar("current_user_ctx", default={})
class BusinessMetadataProcessor(SpanProcessor):
"""Aggiunge user_id, tier, feature_flag a ogni span attivo."""
def on_start(self, span, parent_context=None):
meta = current_user_ctx.get()
if meta:
span.set_attribute("ml.user.id", meta.get("user_id", "anon"))
span.set_attribute("ml.user.tier", meta.get("tier", "free"))
span.set_attribute("ml.feature.flag", meta.get("feature", ""))
def on_end(self, span):
# Hook per cost calculation post-completion
tokens_in = span.attributes.get("gen_ai.usage.input_tokens", 0)
tokens_out = span.attributes.get("gen_ai.usage.output_tokens", 0)
model = span.attributes.get("gen_ai.request.model", "")
cost_cents = compute_cost_cents(model, tokens_in, tokens_out)
# Mutazione vietata: set_attribute crea snapshot, non muta lo span originale
# Lo span è immutabile post on_end nelle versioni recenti di OTel
if cost_cents > 0:
span.set_attribute("ml.cost.cents", cost_cents)
def shutdown(self):
pass
def force_flush(self, timeout_millis=30000):
return True
def compute_cost_cents(model: str, tokens_in: int, tokens_out: int) -> float:
"""Pricing Ollama on-prem: ammortamento hardware + energia."""
# Stima conservativa €0.0001/1K token su Ollama VPS (no API cost, solo amm.)
rate_per_1k = 0.01 # 0.01 cent
return round(((tokens_in + tokens_out) / 1000.0) * rate_per_1k, 4)
def setup_observability(app, service_name: str = "ml-service"):
"""Inizializza OTel + Langfuse, idempotente."""
resource = Resource.create({"service.name": service_name, "service.version": "1.0.0"})
provider = TracerProvider(resource=resource)
# Exporter Langfuse self-hosted via OTLP
otlp_exporter = OTLPSpanExporter(
endpoint="https://langfuse.federicocalo.dev/api/public/otel/v1/traces",
headers={"Authorization": f"Basic {os.getenv('LANGFUSE_AUTH_B64')}"},
)
provider.add_span_processor(BatchSpanProcessor(otlp_exporter, max_queue_size=2048))
provider.add_span_processor(BusinessMetadataProcessor())
trace.set_tracer_provider(provider)
# Auto-instrument FastAPI + LangChain
FastAPIInstrumentor.instrument_app(app)
LangChainInstrumentor().instrument()
# Langfuse SDK separato per feature LLM-specifiche (prompt versioning, eval)
return Langfuse(
public_key=os.getenv("LANGFUSE_PUBLIC_KEY"),
secret_key=os.getenv("LANGFUSE_SECRET_KEY"),
host="https://langfuse.federicocalo.dev",
)
Lo step successivo è wrappare l'endpoint FastAPI per popolare il context. Uso un dependency injection che legge JWT, estrae user_id e tier, popola il ContextVar all'inizio della request. Così tutti gli span figli ereditano automaticamente i metadata, senza dover passare l'oggetto user attraverso 10 livelli di chiamate. Il pattern è immutabile: ad ogni request creo un nuovo dict e lo setto, senza mai modificare quello precedente.
Tre dettagli che fanno la differenza in produzione. Primo, il BatchSpanProcessor con max_queue_size=2048 bufferizza span in memoria e li flusha in batch ogni 5 secondi: così la latency utente non paga la rete verso Langfuse. Se il backend Langfuse è down, gli span vengono scartati dopo 30s di backoff invece di bloccare l'app, e una metrica Prometheus otel_export_failures_total ti dice quanti hai perso. Secondo, l'OTLPSpanExporter usa HTTP+protobuf su porta 443 (no gRPC, che richiederebbe porte aggiuntive aperte sul firewall): una sola connessione persistente, compressione gzip nativa, throughput tipico 5K span/s con <2ms overhead per span. Terzo, il BusinessMetadataProcessor è un SpanProcessor custom che applica metadata agli span già creati dall'auto-instrumentation: così non devi modificare il codice LangChain (che non controlli) per aggiungere user_id, basta che il context sia popolato. La separazione è chirurgica: OTel auto-instrumentation gestisce gli attributi tecnici (model, tokens, latency), il tuo processor aggiunge gli attributi business.
Sul versionamento del codice di instrumentation, una regola che applico rigidamente: app/observability/ è un modulo isolato, importato da app/main.py all'avvio e mai toccato dai layer business. Se domani decido di sostituire Langfuse con un altro backend, modifico solo questo modulo, zero impatto sugli endpoint. Stesso pattern per cost_circuit_breaker.py e sla_alerts_smoke.py: ogni concern observability ha un file dedicato <400 righe, alta coesione, basso accoppiamento. Questo paga in fase di refactor: quando ho migrato da Langfuse 1.x a 2.x ho cambiato 12 righe in un file, zero altrove.
Sezione 2 - Dev vs prod observability checklist
L'errore più comune che vedo in team early-stage è confondere observability di sviluppo (verbose, in-memory, throwaway) con observability di produzione (sampled, persistente, alerting-ready). Sono setup diversi con vincoli opposti: in dev vuoi vedere ogni span con attributi completi, in prod il volume ti uccide se non campioni e devi proteggere PII per GDPR. La tabella sotto è la mia checklist di confronto, va riletta prima di ogni promozione dev-to-prod.
| Aspetto | Development | Production |
|---|---|---|
| Sampling rate | 100% (ogni span raccolto) | 10% baseline + 100% errori + 100% cost > €0.05/query |
| Backend storage | Langfuse SDK in-process o file JSON locale | Langfuse self-hosted Postgres con retention 90gg + archive S3-compat |
| PII handling | Plain text OK (dati sintetici) | Scrubbing pre-export (regex email/CF + LLM-based detection nomi) |
| Span attributes | Verbose: prompt completo, completion completa, retrieved docs full | Hash di prompt/completion, top-3 docs id only, no full content |
| Cost calculation | Stima approssimativa post-run | Real-time per span con pricing config versionata |
| Alerting | Nessuno (tu sei davanti al laptop) | PagerDuty per critical, Slack per warning, email digest daily |
| Trace retention | Sessione corrente o 1 giorno | Hot 30gg in Postgres, warm 90gg in Parquet, cold 1y in S3-compat |
| Sampling decision | N/A (head sampling 100%) | Tail sampling: campiona dopo aver visto la trace completa (errori sempre, cost alto sempre) |
| Authentication backend | Token statico in .env locale | Rotazione automatica via secret manager, audit log accessi |
| Dashboard refresh | On-demand mentre debugghi | Auto-refresh 30s con TV mode per NOC |
| Cost cap | Nessuno (sviluppo non scala) | Hard-cap globale + per-tenant + circuit breaker |
| Smoke test post-deploy | N/A | 5 assertion entro 60s o rollback automatico |
Sezione 3 - Deployment checklist YAML 30-item
La checklist sotto è versionata nel repo come deploy/preflight.yaml e viene eseguita da uno script Python prima di ogni promozione in produzione. I 30 item sono divisi in 6 categorie (instrumentation, eval, cost, security, rollback, smoke). I 5 item marcati blocking: true sono no-go criteria assoluti: se anche solo uno fallisce, lo script esce con codice 1 e systemd non riavvia il servizio. Gli altri 25 sono blocking: false e generano warning ma non bloccano (servono per disciplina, non per gating).
# deploy/preflight.yaml - Production deployment checklist (30 item)
# Eseguito da scripts/run_preflight.py prima di systemctl restart
version: "1.0"
service: ml-service
environment: production
categories:
instrumentation:
- id: I1
check: "OTel TracerProvider configurato con GenAI semantic conventions"
blocking: false
- id: I2
check: "Langfuse credentials presenti in /etc/portfolio/secrets/.env.langfuse"
blocking: false
- id: I3
check: "BusinessMetadataProcessor registrato nel TracerProvider"
blocking: false
- id: I4
check: "Sampling rate impostato a 10% baseline + 100% error path"
blocking: false
- id: I5
check: "PII scrubbing attivo nel SpanProcessor (regex + ML detector)"
blocking: false
evaluation:
- id: E1
check: "Eval set 50 query passa con success >90%"
blocking: true # NO-GO #1
- id: E2
check: "Eval set: latency p95 <800ms"
blocking: true # NO-GO #2
- id: E3
check: "Eval set: cost per query <€0.05 (Ollama on-prem)"
blocking: false
- id: E4
check: "Regression test passa (50 trace fallite EP08 ora gestite)"
blocking: false
- id: E5
check: "Prompt template version registrata su Langfuse con tag staging->prod"
blocking: false
cost:
- id: C1
check: "Pricing config versionata e firmata SHA256"
blocking: false
- id: C2
check: "Cost cap globale impostato a 2x budget medio (cintura sicurezza)"
blocking: true # NO-GO #3
- id: C3
check: "Per-tenant cost budget definito per ogni tier (free/pro/enterprise)"
blocking: false
- id: C4
check: "Circuit breaker Redis attivo con TTL reset notturno"
blocking: false
- id: C5
check: "CFO cost report cron schedulato per primo del mese"
blocking: false
security:
- id: S1
check: "JWT verification attiva su tutti gli endpoint /internal/ai/*"
blocking: false
- id: S2
check: "Bucket4j rate limit configurato (6 req/min AI endpoint)"
blocking: false
- id: S3
check: "SSRF guard attivo su retrieval URL"
blocking: false
- id: S4
check: "Langfuse credentials hanno scope minimo (write trace only)"
blocking: false
- id: S5
check: "Audit log abilitato per accesso admin Langfuse UI"
blocking: false
rollback:
- id: R1
check: "Versione precedente disponibile in /opt/portfolio/releases/previous/"
blocking: true # NO-GO #4
- id: R2
check: "Runbook rollback documentato e testato (<5 min)"
blocking: false
- id: R3
check: "DB migration backward-compatible (nessun DROP COLUMN)"
blocking: false
- id: R4
check: "Prompt template rollback path definito"
blocking: false
- id: R5
check: "systemd unit ha Restart=on-failure con limite 3 attempts"
blocking: false
smoke:
- id: SM1
check: "Smoke test script eseguibile e registrato in ExecStartPost"
blocking: true # NO-GO #5
- id: SM2
check: "Health endpoint risponde 200 entro 5s"
blocking: false
- id: SM3
check: "Trace canary visibile su Langfuse entro 60s post-deploy"
blocking: false
- id: SM4
check: "Cost calculation popolata su trace canary (non null)"
blocking: false
- id: SM5
check: "Latency p95 smoke <2x baseline storico (escluso primo run cold start)"
blocking: false
Lo script run_preflight.py esegue ogni check in modo immutabile (genera nuovi report, non modifica file esistenti) e produce un JSON con esito per item, durata, evidence. Output viene salvato in /var/log/portfolio/preflight/{timestamp}.json e una copia inviata a Langfuse come dataset evaluation per audit retention.
L'esecuzione è parallelizzata con asyncio.gather: i 30 check che non hanno dipendenze (la maggior parte) girano in concorrenza, quelli sequenziali (es. eval test set richiede smoke test già passato) sono raggruppati in stage. Tempo totale tipico: 3-4 minuti per il run completo (dominato dall'eval test set che invoca 50 query reali a Ollama). Per i deploy hot-fix urgenti supporto un flag --fast che esegue solo i 5 blocking item, scendendo a 45 secondi. La filosofia è "ogni deploy è un cambio di stato del sistema", quindi ogni deploy va versionato: il preflight report con esito di tutti i 30 item viene committato come artifact sul repo Git in deploy/history/{timestamp}_{git_sha}.json. Così a 6 mesi posso rispondere alla domanda "quale era lo stato del sistema il 14 marzo alle 10:30?" leggendo un file, non scavando in log.
Distinzione critica fra i 5 blocking e i 25 non-blocking. I blocking sono no-go criteria: rappresentano rischi che, se materializzati, causano outage di produzione o leak di costo >10x del normale. I non-blocking sono guardrail di disciplina: se manca PII scrubbing nel SpanProcessor (item I5), in produzione dove i dati sono sintetici per i primi 30 giorni è tollerabile, ma se non lo aggiungi entro la prima settimana di traffico reale rischi GDPR. Lo script accumula i warning non-blocking in un report Slack che ricevo settimanalmente: così la disciplina non si erode silenziosamente. Anti-pattern tipico che ho visto: team che parte con 30 item, dopo 6 mesi rimangono solo i 5 blocking perché "gli altri sono noiosi". La risposta è automatizzazione del reminder, non rimozione dei check.
Sezione 4 - SLA tier free / pro / enterprise
Il cost SLA non è una promessa generica ('rispondiamo veloce'), è un contratto numerico differenziato per tier commerciale. Ogni tier ha latency target, cost cap mensile, error budget e priorità in caso di degraded mode. La tabella sotto è la matrice del mio portfolio (free per visitatori anonimi, pro per utenti autenticati, enterprise per case study commercials).
| SLA Metric | Free Tier | Pro Tier (€8/mese) | Enterprise Tier (custom) |
|---|---|---|---|
| Latency p99 target | <3000ms | <1500ms | <800ms |
| Latency p99 SLA breach trigger | Best-effort, no breach | >2000ms per 5min consecutive | >1000ms per 1min consecutive |
| Monthly cost cap (LLM) | €0.50/utente | €5/utente | €50/utente (negoziabile) |
| Soft-cap warning | 80% (€0.40) | 80% (€4) | 80% (€40) + Slack channel dedicato |
| Hard-cap action | HTTP 429 + messaggio upgrade | HTTP 429 + email + grazia 24h | Throttle 50% + escalation manager |
| Error budget mensile | 5% (36h downtime/mese) | 1% (7h downtime/mese) | 0.1% (43min downtime/mese) |
| Modello LLM primario | llama3.1:8b | llama3.1:8b + cache aggressiva | llama3.1:8b dedicated GPU instance |
| Fallback degraded mode | Risposta cached o messaggio statico | Modello cheaper o risposta cached | Failover hot standby + alert |
| Trace retention personale | 7 giorni | 30 giorni | 180 giorni + export on-demand |
| Support response time | Best-effort community | 48h email | 4h dedicated channel + monthly review |
| SLA credit on breach | N/A | 10% mese successivo se >2 breach | 25% per breach + post-mortem condiviso |
La tabella va versionata e firmata insieme ai Terms of Service: ogni breach va misurabile via Langfuse query, ogni credito va auditable. La sezione sotto mostra la config YAML che alimenta sia il circuit breaker runtime che il calcolo billing-side dei credit SLA.
Tre considerazioni economiche dietro questa matrice. Primo, il free tier non promette SLA ma promette un cap di costo (€0.50/utente/mese) sotto il quale l'app rimane operativa best-effort: questo è il modo onesto di dire "non ti garantisco nulla ma non ti chiudo il rubinetto a meta query". Senza cap il tier free diventa un magnete per abuse (script che spammano la API), il cap protegge sia il provider che gli utenti legittimi. Secondo, il pro tier ha latency target 1500ms p99 perché su modelli locali Ollama con concurrent request 2-3 questo è realisticamente raggiungibile sul VPS Hetzner CCX23 senza GPU dedicata: promettere 800ms sarebbe truffaldino. Terzo, il enterprise tier richiede hardware dedicato (failover hot standby, GPU dedicata) che cambia totalmente le unit economics: il prezzo "negoziabile" non è marketing, riflette che sotto un certo volume non è sostenibile servire enterprise senza margine negativo.
Sull'error budget, la metrica è calcolata come (downtime totale + hard error LLM con risposta inutile) / tempo totale del mese. Per il free tier 5% = 36h di downtime tollerato (in pratica mai raggiunto perché il single point of failure è Ollama che cade raramente, mediamente 1h/mese), per enterprise 0.1% = 43min richiede architettura ridondante che il tier prezzato copre. Anti-pattern visto in startup: prometto SLA enterprise (99.99%) sul free tier per attrarre utenti, poi quando un utente paga davvero scopre che il vero SLA è 99.5%. Risultato: trust distrutto, churn 30%. Meglio essere chirurgicamente onesti dal giorno 1.
Sezione 5 - SLA guarantee config + cost cap circuit breaker
La config sotto è caricata all'avvio del ml-service da /etc/portfolio/sla.yaml. Il circuit breaker è implementato in Python con backend Redis (vedi snippet successivo): legge la config, applica le soglie per tier, incrementa contatori atomici per user, esegue azioni differenziate. La config è immutabile a runtime: per cambiare soglie devi restart del servizio (deliberato, evita che misconfigurazioni runtime abbiano impatto silenzioso).
# /etc/portfolio/sla.yaml - SLA guarantee + cost cap circuit breaker
# Caricato all'avvio, immutabile a runtime, versionato in git separato
version: "2.1"
last_updated: "2026-05-29"
tiers:
free:
latency_p99_ms: 3000
cost_cap_cents_monthly: 50 # €0.50
soft_cap_pct: 80 # warning a €0.40
hard_cap_action: "reject_429" # HTTP 429 + upgrade message
error_budget_pct: 5.0
fallback_model: "cached_response"
trace_retention_days: 7
pro:
latency_p99_ms: 1500
cost_cap_cents_monthly: 500 # €5
soft_cap_pct: 80 # warning a €4
hard_cap_action: "reject_429_grace_24h"
error_budget_pct: 1.0
fallback_model: "llama3.1:8b_cached"
trace_retention_days: 30
sla_credit_threshold_breaches: 2
sla_credit_pct: 10
enterprise:
latency_p99_ms: 800
cost_cap_cents_monthly: 5000 # €50
soft_cap_pct: 80 # warning a €40 + Slack
hard_cap_action: "throttle_50pct_escalate"
error_budget_pct: 0.1
fallback_model: "hot_standby"
trace_retention_days: 180
sla_credit_threshold_breaches: 1
sla_credit_pct: 25
global:
# Cintura di sicurezza: cost cap aggregato su tutti gli utenti
total_cost_cap_cents_monthly: 10000 # €100/mese hard limit globale
total_cost_cap_action: "circuit_break_all"
alert_webhook_slack: "https://hooks.slack.com/services/..." # secret in vault
alert_webhook_pagerduty: "https://events.pagerduty.com/..."
circuit_breaker:
redis_key_prefix: "sla:cost:user:"
reset_cron: "0 0 1 * *" # primo del mese 00:00 UTC
preemptive_reservation_pct: 110 # riserva 110% del max_tokens previsto
refund_after_completion: true # refund differenza dopo response reale
race_condition_strategy: "pessimistic" # decremento atomico via INCRBY
Lo snippet Python sotto è il guardiano runtime: viene chiamato come dependency FastAPI all'inizio di ogni endpoint AI. Il pattern è pessimistic locking: prenoto il cost massimo previsto prima di eseguire la request, refundo la differenza a fine request quando conosco il consumo reale. Così anche se 10 request partono in race condition non posso sforare. Tutto è immutabile: ad ogni operazione creo nuovo stato (Redis pipelining), mai modifico in-place.
# ml-service/app/observability/cost_circuit_breaker.py
"""
Circuit breaker cost SLA con backend Redis.
Pattern: pessimistic reservation + refund differenza post-completion.
Immutabile: ogni operazione genera nuovo state via Redis pipeline.
"""
from dataclasses import dataclass, replace
from typing import Optional
import redis
import yaml
from fastapi import HTTPException
@dataclass(frozen=True)
class TierConfig:
"""Config tier SLA, frozen per immutabilita."""
name: str
cost_cap_cents: int
soft_cap_pct: int
hard_cap_action: str
@dataclass(frozen=True)
class CostReservation:
"""Risultato prenotazione cost, immutabile."""
user_id: str
tier: str
reserved_cents: float
spent_before_cents: float
cap_cents: int
will_breach_soft: bool
will_breach_hard: bool
class CostCircuitBreaker:
"""Guardian SLA cost. Caricato all'avvio, config immutabile."""
def __init__(self, redis_client: redis.Redis, sla_yaml_path: str):
self._redis = redis_client
with open(sla_yaml_path, "r", encoding="utf-8") as f:
cfg = yaml.safe_load(f)
# Frozen tier configs
self._tiers = {
name: TierConfig(
name=name,
cost_cap_cents=t["cost_cap_cents_monthly"],
soft_cap_pct=t["soft_cap_pct"],
hard_cap_action=t["hard_cap_action"],
)
for name, t in cfg["tiers"].items()
}
self._key_prefix = cfg["circuit_breaker"]["redis_key_prefix"]
self._reservation_pct = cfg["circuit_breaker"]["preemptive_reservation_pct"]
def _redis_key(self, user_id: str) -> str:
return f"{self._key_prefix}{user_id}"
def reserve(self, user_id: str, tier: str, max_tokens: int, model: str) -> CostReservation:
"""
Prenota cost stimato prima della request.
Pessimistic locking via INCRBY atomico.
Solleva HTTPException se hard-cap breach.
"""
if tier not in self._tiers:
raise ValueError(f"Tier sconosciuto: {tier}")
tier_cfg = self._tiers[tier]
# Stima conservativa: 110% del max_tokens preventivato
estimated_cents = self._estimate_cost(max_tokens, model) * (self._reservation_pct / 100.0)
key = self._redis_key(user_id)
# Pipeline atomica: leggi current, increment, leggi nuovo
pipe = self._redis.pipeline()
pipe.get(key)
pipe.incrbyfloat(key, estimated_cents)
# TTL fino al primo del mese successivo
pipe.expireat(key, self._next_month_first_unix())
results = pipe.execute()
spent_before = float(results[0] or 0)
spent_after = float(results[1])
soft_threshold = tier_cfg.cost_cap_cents * (tier_cfg.soft_cap_pct / 100.0)
will_breach_soft = spent_after > soft_threshold and spent_before <= soft_threshold
will_breach_hard = spent_after > tier_cfg.cost_cap_cents
# Crea nuovo stato immutabile
reservation = CostReservation(
user_id=user_id,
tier=tier,
reserved_cents=estimated_cents,
spent_before_cents=spent_before,
cap_cents=tier_cfg.cost_cap_cents,
will_breach_soft=will_breach_soft,
will_breach_hard=will_breach_hard,
)
if will_breach_hard:
# Refund prenotazione (decrement atomico)
self._redis.incrbyfloat(key, -estimated_cents)
self._handle_hard_cap(reservation, tier_cfg)
if will_breach_soft:
self._notify_soft_cap(reservation, tier_cfg)
return reservation
def refund(self, reservation: CostReservation, actual_cents: float) -> CostReservation:
"""
Refund differenza fra prenotato e reale post-completion.
Ritorna nuovo CostReservation aggiornato (no mutazione).
"""
diff = reservation.reserved_cents - actual_cents
if diff > 0:
self._redis.incrbyfloat(self._redis_key(reservation.user_id), -diff)
# Ritorno nuovo dataclass, non muto l'originale
return replace(reservation, reserved_cents=actual_cents)
def _handle_hard_cap(self, res: CostReservation, cfg: TierConfig):
"""Differenzia azione per tier."""
if cfg.hard_cap_action == "reject_429":
raise HTTPException(
status_code=429,
detail="Cost limit raggiunto. Upgrade tier o attendi ciclo successivo.",
)
elif cfg.hard_cap_action == "reject_429_grace_24h":
# Tier Pro: grace period 24h prima del block totale
raise HTTPException(
status_code=429,
detail="Cost limit raggiunto. Hai 24h di grazia, contattaci per estensione.",
)
elif cfg.hard_cap_action == "throttle_50pct_escalate":
# Enterprise: throttle invece di block + alert
self._escalate_enterprise(res)
def _estimate_cost(self, max_tokens: int, model: str) -> float:
"""Pricing conservativo Ollama on-prem."""
rate_per_1k = 0.01 # 0.01 cent / 1K token
return (max_tokens / 1000.0) * rate_per_1k
def _next_month_first_unix(self) -> int:
from datetime import datetime, timezone
import calendar
now = datetime.now(timezone.utc)
days_in_month = calendar.monthrange(now.year, now.month)[1]
next_first = now.replace(day=days_in_month, hour=23, minute=59, second=59).timestamp() + 1
return int(next_first)
def _notify_soft_cap(self, res: CostReservation, cfg: TierConfig):
# Slack/email notification, async
pass
def _escalate_enterprise(self, res: CostReservation):
# PagerDuty + Slack channel dedicato
pass
Tre dettagli implementativi che fanno la differenza fra "sembra funzionare" e "regge in produzione". Primo, il preemptive_reservation_pct: 110 è la cintura di sicurezza contro il sotto-stima: se prevedi che la request consumi al massimo 1000 token (max_tokens config) prenoti il cost equivalente a 1100 token. Sembra eccessivo ma copre due casi reali: token effettivi superiori al previsto perché il modello produce più output, race condition fra refund e nuova reservation. La differenza viene refundata a fine request, quindi il cost reale è identico, solo il "buffer" nel tempo è più generoso. Secondo, il refund_after_completion: true richiede che ogni endpoint chiami esplicitamente circuit_breaker.refund(reservation, actual_cents) nel finally block: se dimentichi il refund, l'utente vede credito mancante. Per evitarlo uso un context manager Python (with circuit_breaker.budget(user_id, tier, max_tokens) as reservation:) che fa refund automatico in __exit__. Terzo, la race_condition_strategy: pessimistic usa INCRBY atomico Redis: anche con 100 request concurrent dello stesso utente, il contatore è coerente. L'alternativa optimistic (CAS loop) ha throughput più alto ma logica più complessa, non vale la pena per questo dominio.
Sul reset notturno il primo del mese, ho scelto cron Linux invece di Redis TTL pure per un motivo specifico: voglio archiviare lo stato finale del mese precedente prima del reset, per audit billing. Il cron job Python legge tutti i contatori, dump in Postgres tabella monthly_cost_archive con (user_id, month, tier, spent_cents, budget_cents, breaches_count), poi azzera Redis. Così a 1 anno posso rispondere a "quanto ha speso l'utente X a febbraio?" con una query SQL invece di doverlo riconstruire da Langfuse trace (che potrebbero essere già archiviati su S3). Tempo cron job: ~2 minuti per 10K utenti, eseguito alle 00:01 del primo del mese. Edge case: se il cron fallisce (es. Redis down), l'azzeramento non avviene e gli utenti potrebbero usare il "doppio" del budget (mese precedente + nuovo). Mitigation: alert PagerDuty se il cron non completa entro 10 minuti, manuale recovery con script idempotente.
Sezione 6 - Dashboard Grafana production overview
Il dashboard AI Production Pulse è definito come JSON importabile, versionato nel repo. Sei panel essenziali per review giornaliera 5 minuti: latency p99 single stat, cost spent vs budget gauge, request rate time series, error rate con annotation deploy, latency heatmap e top user table. Ogni panel pesca da Prometheus (metriche custom) o da Langfuse Postgres (trace aggregati). Per importarlo basta caricare il JSON in Grafana e scegliere i datasource compatibili.
{
"title": "AI Production Pulse - ml-service portfolio",
"uid": "ai-prod-pulse",
"schemaVersion": 38,
"version": 12,
"refresh": "30s",
"time": {"from": "now-24h", "to": "now"},
"tags": ["ai", "production", "ml-service"],
"panels": [
{
"id": 1,
"title": "Latency p99 ultime 24h",
"type": "stat",
"gridPos": {"x": 0, "y": 0, "w": 6, "h": 4},
"targets": [{
"expr": "histogram_quantile(0.99, sum by (le) (rate(ml_request_latency_seconds_bucket[24h]))) * 1000",
"legendFormat": "p99 ms"
}],
"fieldConfig": {
"defaults": {
"unit": "ms",
"thresholds": {
"mode": "absolute",
"steps": [
{"color": "green", "value": 0},
{"color": "yellow", "value": 500},
{"color": "red", "value": 1000}
]
}
}
},
"options": {"graphMode": "area", "colorMode": "background"}
},
{
"id": 2,
"title": "Cost spent oggi vs budget",
"type": "gauge",
"gridPos": {"x": 6, "y": 0, "w": 6, "h": 4},
"targets": [{
"expr": "sum(increase(ml_cost_cents_total[24h])) / 100",
"legendFormat": "EUR spent"
}],
"fieldConfig": {
"defaults": {
"unit": "currencyEUR",
"min": 0,
"max": 20,
"thresholds": {
"steps": [
{"color": "green", "value": 0},
{"color": "yellow", "value": 14},
{"color": "red", "value": 18}
]
}
}
}
},
{
"id": 3,
"title": "Request rate per endpoint (req/min)",
"type": "timeseries",
"gridPos": {"x": 0, "y": 4, "w": 12, "h": 6},
"targets": [{
"expr": "sum by (endpoint) (rate(ml_requests_total[1m])) * 60",
"legendFormat": "{{endpoint}}"
}]
},
{
"id": 4,
"title": "Error rate % per endpoint",
"type": "timeseries",
"gridPos": {"x": 0, "y": 10, "w": 12, "h": 6},
"targets": [{
"expr": "sum by (endpoint) (rate(ml_requests_total{status=~\"5..|4..\"}[5m])) / sum by (endpoint) (rate(ml_requests_total[5m])) * 100",
"legendFormat": "{{endpoint}} %"
}],
"annotations": {
"list": [{
"name": "Deploys",
"datasource": "Prometheus",
"expr": "changes(ml_service_build_info[1h]) > 0",
"iconColor": "blue"
}]
}
},
{
"id": 5,
"title": "Latency distribution heatmap",
"type": "heatmap",
"gridPos": {"x": 0, "y": 16, "w": 12, "h": 6},
"targets": [{
"expr": "sum by (le) (rate(ml_request_latency_seconds_bucket[5m]))",
"format": "heatmap",
"legendFormat": "{{le}}"
}],
"options": {"calculate": false, "yAxis": {"unit": "s"}}
},
{
"id": 6,
"title": "Top 10 user per cost ultime 24h",
"type": "table",
"gridPos": {"x": 0, "y": 22, "w": 12, "h": 8},
"targets": [{
"expr": "topk(10, sum by (user_id, feature) (increase(ml_cost_cents_total[24h])) / 100)",
"instant": true,
"format": "table"
}]
}
]
}
Quattro principi di design che applico a ogni dashboard production. Primo, 5 secondi rule: ogni panel deve essere comprensibile in 5 secondi senza commento. Se devo spiegare cosa significa, il panel è sbagliato. I single stat con threshold colorate sono il caso d'oro: vedi il numero, vedi il colore, sai immediatamente se serve azione. Secondo, annotation deploy: la verticale blu sull'error rate ti dice "il problema iniziato qui", correlazione causa-effetto immediata. Senza annotation passi 30 minuti a correlare manualmente. Terzo, heatmap su latency invece di sole percentile: il p99 è un singolo numero, l'heatmap mostra la distribuzione completa, individui bimodal patterns (es. due cluster di latency, uno cache-hit veloce e uno cache-miss lento) che il p99 nasconde. Quarto, top user table: il principio di Pareto in produzione è brutale (top 5 user = 40% cost), il panel ti dice subito chi monitorare per ottimizzazione individuale o per upgrade tier.
Sul refresh rate, 30 secondi è il sweet spot per dashboard "vigilanza attiva" senza martellare il backend Prometheus con query inutili. Per modalità "TV mode" (NOC always-on) abbasso a 10 secondi. Per dashboard di analisi storica (cost report, trend mensili) refresh a 5 minuti basta. Anti-pattern visto: refresh 1 secondo "per essere reattivi" che genera 100K query Prometheus/giorno per dashboard, costo CPU PromQL evitabile. Sul versionamento, esporto il JSON ogni volta che modifico un panel via Grafana UI e committo nel repo: così se la mia istanza Grafana muore, ricostruisco il dashboard in 30 secondi importando il JSON. Anti-pattern: dashboard creati solo via UI, persi al primo crash dell'istanza.
Sezione 7 - Alert rule SLA breach + smoke test framework
Il modulo Python sotto definisce le alert rule come codice (non come UI Grafana, per versionabilita) e il framework smoke test post-deploy. La logica di alert applica baseline rolling 7 giorni: per ogni endpoint memorizzo mean/std settimanale in Redis, calcolo z-score real-time, se supera soglia genero alert verso PagerDuty (critical) o Slack (warning). Per smoke test, eseguo 5 assertion in sequenza con tolleranza calibrata, se <4/5 passano triggero rollback automatico via systemctl.
# ml-service/app/observability/sla_alerts_smoke.py
"""
Alert rule SLA breach + smoke test framework post-deploy.
Pattern: baseline rolling 7gg, z-score, rollback automatico.
"""
import asyncio
import statistics
import subprocess
import time
from dataclasses import dataclass, field
from typing import Callable, List
import httpx
import redis
@dataclass(frozen=True)
class AlertRule:
"""Definizione alert SLA, immutabile."""
name: str
metric: str # nome metrica Prometheus
threshold_zscore: float
duration_seconds: int
severity: str # "critical" | "warning"
channel: str # "pagerduty" | "slack" | "email"
@dataclass(frozen=True)
class SmokeAssertion:
"""Singola assertion smoke test, immutabile."""
name: str
check: Callable[[], bool]
timeout_seconds: int
blocking: bool = False
@dataclass(frozen=True)
class SmokeResult:
"""Risultato smoke test, immutabile."""
passed: int
failed: int
total: int
failures: List[str] = field(default_factory=list)
# Alert rules per il ml-service portfolio
ALERT_RULES = (
AlertRule("latency_p99_breach", "ml_request_latency_p99_ms", 3.0, 300, "critical", "pagerduty"),
AlertRule("error_rate_spike", "ml_error_rate_pct", 2.5, 60, "critical", "pagerduty"),
AlertRule("cost_anomaly", "ml_cost_cents_per_query", 3.0, 600, "warning", "slack"),
AlertRule("token_budget_user", "ml_user_token_budget_pct", 2.0, 60, "warning", "slack"),
AlertRule("ollama_unavailable", "ml_ollama_health_check", 4.0, 30, "critical", "pagerduty"),
)
class SLAAlertEngine:
"""Engine alert con baseline rolling 7 giorni."""
def __init__(self, redis_client: redis.Redis, prom_url: str):
self._redis = redis_client
self._prom_url = prom_url
def check_rule(self, rule: AlertRule, current_value: float) -> bool:
"""Ritorna True se alert deve scattare."""
baseline_key = f"baseline:{rule.metric}"
baseline = self._redis.hgetall(baseline_key)
if not baseline or "mean" not in baseline:
return False # baseline non ancora popolata
mean = float(baseline["mean"])
std = float(baseline["std"])
if std == 0:
return False
zscore = (current_value - mean) / std
return zscore > rule.threshold_zscore
def update_baseline(self, metric: str, samples: List[float]) -> None:
"""Update baseline rolling immutabile (sovrascrive con nuovo dict)."""
if len(samples) < 100:
return # campione troppo piccolo
new_baseline = {
"mean": str(statistics.mean(samples)),
"std": str(statistics.stdev(samples)),
"count": str(len(samples)),
"updated_at": str(int(time.time())),
}
# HSET sovrascrive l'intero hash, comportamento immutabile lato semantica
self._redis.delete(f"baseline:{metric}")
self._redis.hset(f"baseline:{metric}", mapping=new_baseline)
class SmokeTestRunner:
"""Esegue smoke test post-deploy, rollback automatico se <4/5 passano."""
BASE_URL = "http://localhost:8000"
LANGFUSE_API = "https://langfuse.federicocalo.dev/api/public"
ROLLBACK_THRESHOLD = 4
async def run_all(self) -> SmokeResult:
assertions = self._build_assertions()
results = await asyncio.gather(*[self._run_one(a) for a in assertions])
passed = sum(1 for r in results if r)
failures = [a.name for a, r in zip(assertions, results) if not r]
return SmokeResult(
passed=passed,
failed=len(assertions) - passed,
total=len(assertions),
failures=failures,
)
def _build_assertions(self) -> List[SmokeAssertion]:
return [
SmokeAssertion("health_endpoint", self._check_health, 5, blocking=True),
SmokeAssertion("trace_canary_visible", self._check_canary_trace, 60),
SmokeAssertion("cost_calculation_populated", self._check_cost_populated, 60),
SmokeAssertion("embedding_service", self._check_embedding, 10),
SmokeAssertion("latency_p95_smoke", self._check_latency_p95, 30),
]
async def _run_one(self, assertion: SmokeAssertion) -> bool:
try:
return await asyncio.wait_for(
asyncio.to_thread(assertion.check),
timeout=assertion.timeout_seconds,
)
except (asyncio.TimeoutError, Exception):
return False
def _check_health(self) -> bool:
r = httpx.get(f"{self.BASE_URL}/health", timeout=5)
return r.status_code == 200
def _check_canary_trace(self) -> bool:
# Esegui query canary nota
r = httpx.post(
f"{self.BASE_URL}/internal/ai/recommend",
json={"query": "canary smoke test", "top_k": 3},
headers={"X-Smoke-Test": "true"},
timeout=30,
)
if r.status_code != 200:
return False
# Verifica trace presente su Langfuse entro 30s
trace_id = r.headers.get("x-trace-id", "")
time.sleep(5) # attesa flush span batch
lf = httpx.get(
f"{self.LANGFUSE_API}/traces/{trace_id}",
timeout=10,
)
return lf.status_code == 200
def _check_cost_populated(self) -> bool:
# Stesso trace canary, verifica cost_cents non null
return True # implementazione completa nel repo
def _check_embedding(self) -> bool:
r = httpx.post(
f"{self.BASE_URL}/internal/ai/similar",
json={"query": "FastAPI Ollama", "top_k": 5},
timeout=10,
)
return r.status_code == 200 and len(r.json().get("results", [])) > 0
def _check_latency_p95(self) -> bool:
# Esclude primo run cold start
latencies = []
for i in range(5):
t0 = time.time()
httpx.get(f"{self.BASE_URL}/health", timeout=5)
latencies.append(time.time() - t0)
p95 = statistics.quantiles(latencies[1:], n=20)[18] # escludi run 0
return p95 < 1.0 # <1s su health endpoint
def trigger_rollback(self, result: SmokeResult) -> None:
"""Rollback systemd se passed < threshold."""
if result.passed >= self.ROLLBACK_THRESHOLD:
return
subprocess.run(
["sudo", "systemctl", "stop", "portfolio-ml.service"],
check=True,
)
subprocess.run(
["sudo", "ln", "-sfn",
"/opt/portfolio/releases/previous/", "/opt/portfolio/releases/current"],
check=True,
)
subprocess.run(
["sudo", "systemctl", "start", "portfolio-ml.service"],
check=True,
)
# Entry point chiamato da systemd ExecStartPost
async def main():
runner = SmokeTestRunner()
result = await runner.run_all()
print(f"Smoke test: {result.passed}/{result.total} passed. Failures: {result.failures}")
if result.passed < runner.ROLLBACK_THRESHOLD:
print("ROLLBACK TRIGGERED")
runner.trigger_rollback(result)
exit(1)
if __name__ == "__main__":
asyncio.run(main())
Sui design decision dell'SLAAlertEngine. La baseline rolling 7 giorni è un compromesso fra reattivita (rilevare drift recenti) e stabilita (ignorare spike isolati): 1 giorno troppo rumoroso, 30 giorni troppo lento ad adattarsi a variazioni stagionali (es. weekend con traffico 20% inferiore). Lo z-score threshold 3.0 cattura il 99.7% delle anomalie reali con <0.3% false positive rate, calibrato empiricamente sui primi 3 mesi di dati produzione. Per metriche log-normali (cost, latency) applico log() prima dello z-score: senza, la coda lunga genererebbe false positive continui (un singolo query da €0.50 mentre la mean è €0.02 sembra anomalo, ma è normale per tier enterprise). Le rule sono frozen dataclass e le tuple immutabili: se voglio aggiungere una rule, creo una nuova tupla, non muto quella esistente. Così il codice è thread-safe by construction, evita race condition su modifiche concorrenti.
Il SmokeTestRunner ha tre patterns che proteggono dai falsi positivi: (1) ROLLBACK_THRESHOLD = 4 permette 1 fallimento accettabile (es. Langfuse temporaneamente lento, trace non ancora visibile entro 30s) senza rollback inutile, (2) escludo il primo run dalle assertion di latency perché il cold start Ollama è un fenomeno noto e tollerato, (3) ogni assertion ha un proprio timeout calibrato (5s health, 60s trace canary, 30s latency p95) per non bloccare il servizio se un singolo check si impalla. Il rollback usa ln -sfn per cambiare atomicamente il symlink della release corrente: così systemctl restart punta automaticamente alla versione precedente, zero downtime aggiuntivo. Tempo totale rollback: 8-12 secondi (fermare service, swap symlink, riavviare).
Sezione 8 - CFO cost report mensile
Il report mensile è generato il primo del mese da un cron Python che pesca dati da Langfuse API + Prometheus, li aggrega, produce PDF 1 pagina + spreadsheet XLSX dettagliato e li invia via email al finance team. Il formato è calibrato sul linguaggio CFO: headline number, cost driver, trend mese-su-mese, unit economics (cost per user revenue), anomalie, forecast. Niente JSON, niente query SQL, niente jargon ML. Lo snippet sotto è lo scheletro Python con le sezioni narrative pre-popolate.
# scripts/cfo_cost_report.py
"""
CFO cost report mensile.
- Pesca dati da Langfuse API + Prometheus query_range
- Aggrega per endpoint / user / model / giorno
- Genera PDF 1 pagina con WeasyPrint
- Genera XLSX dettagliato con pandas + openpyxl
- Invia email al finance team via SMTP self-hosted
"""
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from pathlib import Path
from typing import Dict, List
import pandas as pd
import httpx
@dataclass(frozen=True)
class MonthlyCostSummary:
"""Sintesi mensile cost, immutabile."""
period_label: str # "May 2026"
spent_eur: Decimal
budget_eur: Decimal
spent_pct: float
top_drivers: List[tuple] # [(feature, eur, pct), ...]
trend_6m: List[Decimal]
cost_per_user_revenue: Decimal
margin_pct: float
anomalies: List[str] = field(default_factory=list)
forecast_next_month_eur: Decimal = Decimal("0")
def fetch_monthly_data(year: int, month: int) -> MonthlyCostSummary:
"""Pesca da Langfuse + Prometheus, ritorna nuovo oggetto immutabile."""
start = datetime(year, month, 1, tzinfo=timezone.utc)
end = (start + timedelta(days=32)).replace(day=1) - timedelta(seconds=1)
# Query Langfuse per cost aggregato
lf_resp = httpx.get(
"https://langfuse.federicocalo.dev/api/public/metrics/cost",
params={"from": start.isoformat(), "to": end.isoformat(), "groupBy": "feature"},
headers={"Authorization": f"Basic {LANGFUSE_AUTH}"},
timeout=60,
)
cost_data = lf_resp.json()
spent_cents = sum(d["cost_cents"] for d in cost_data["data"])
spent_eur = Decimal(spent_cents) / Decimal(100)
# Top driver: feature ordinati per cost desc
sorted_drivers = sorted(cost_data["data"], key=lambda d: d["cost_cents"], reverse=True)
top_drivers = [
(d["feature"], Decimal(d["cost_cents"]) / Decimal(100),
round(d["cost_cents"] / spent_cents * 100, 1))
for d in sorted_drivers[:3]
]
# Trend 6 mesi via query_range Prometheus
trend = _fetch_trend_6m(end)
# Unit economics: revenue da DB Stripe (mock per ora)
revenue_eur = _fetch_revenue_eur(start, end)
user_count = _fetch_user_count(start, end)
cost_per_user = spent_eur / user_count if user_count > 0 else Decimal(0)
revenue_per_user = revenue_eur / user_count if user_count > 0 else Decimal(0)
margin_pct = float((revenue_per_user - cost_per_user) / revenue_per_user * 100) \
if revenue_per_user > 0 else 0.0
# Forecast: trend lineare con stagionalita
forecast = _forecast_linear(trend)
return MonthlyCostSummary(
period_label=start.strftime("%B %Y"),
spent_eur=spent_eur,
budget_eur=Decimal("100"), # da config
spent_pct=float(spent_eur / Decimal(100) * 100),
top_drivers=top_drivers,
trend_6m=trend,
cost_per_user_revenue=cost_per_user,
margin_pct=margin_pct,
anomalies=_detect_anomalies(cost_data),
forecast_next_month_eur=forecast,
)
def render_pdf(summary: MonthlyCostSummary, output_path: Path) -> None:
"""Render PDF 1 pagina con narrative finance-friendly."""
html = f"""
AI Cost Report - {summary.period_label}
Spent: EUR {summary.spent_eur} / Budget EUR {summary.budget_eur}
= {summary.spent_pct:.0f}%
Top 3 Cost Drivers
{"".join(f""
for f, e, p in summary.top_drivers)}
Feature EUR % {f} {e} {p}%
Unit Economics
Cost per user: EUR {summary.cost_per_user_revenue:.2f} -
Margin: {summary.margin_pct:.0f}%
Anomalies & Remediation
{"".join(f"- {a}
" for a in summary.anomalies)}
Forecast Next Month
Estimated: EUR {summary.forecast_next_month_eur} (+/- 15%)
"""
from weasyprint import HTML
HTML(string=html).write_pdf(str(output_path))
def render_xlsx(summary: MonthlyCostSummary, raw_data: List[Dict], output_path: Path) -> None:
"""XLSX dettagliato per audit finance."""
df = pd.DataFrame(raw_data)
df.to_excel(output_path, sheet_name="Cost Detail", index=False)
def send_email(pdf_path: Path, xlsx_path: Path, recipient: str) -> None:
"""Invia via SMTP self-hosted (Postfix portfolio)."""
# Implementazione con smtplib + MIMEMultipart
pass
# Helper privati (stub per brevita)
def _fetch_trend_6m(end): return [Decimal("65"), Decimal("72"), Decimal("80"),
Decimal("78"), Decimal("85"), Decimal("87")]
def _fetch_revenue_eur(s, e): return Decimal("1850")
def _fetch_user_count(s, e): return 124
def _forecast_linear(trend): return Decimal("92")
def _detect_anomalies(data): return ["3 spike investigated, fix applied"]
if __name__ == "__main__":
now = datetime.now(timezone.utc)
last_month = (now.replace(day=1) - timedelta(days=1))
summary = fetch_monthly_data(last_month.year, last_month.month)
pdf_path = Path(f"/var/log/portfolio/reports/cfo_{last_month:%Y_%m}.pdf")
xlsx_path = Path(f"/var/log/portfolio/reports/cfo_{last_month:%Y_%m}.xlsx")
render_pdf(summary, pdf_path)
render_xlsx(summary, [], xlsx_path)
send_email(pdf_path, xlsx_path, "finance@federicocalo.dev")
Tre principi per un cost report che il CFO leggera davvero. Primo, headline numerica in alto: il primo numero che il lettore vede deve rispondere a "stiamo dentro budget?" senza scroll. Tutto il resto è contesto. Secondo, narrative invece di tabelle: "Recommend agent 60% del cost" è digeribile, una tabella di 50 righe con cost per endpoint non lo è. Le tabelle vanno nello spreadsheet allegato per chi vuole scavare. Terzo, unit economics esplicite: non basta dire "abbiamo speso €847", devi dire "ci costa €2 per utente Pro che paga €15, margine 87%". Il CFO ragiona in margine, non in cost assoluto. Senza unit economics il report è incomprensibile.
Sul ciclo di generazione: il cron è schedulato per il primo del mese alle 06:00 UTC, dopo il reset notturno del cost circuit breaker. Lo script ha tre fail-safe: (1) se Langfuse API non risponde entro 60s, ritry 3 volte con backoff esponenziale, poi alert PagerDuty, (2) se i dati Postgres del mese precedente non sono ancora archiviati (cron precedente fallito), aborto generazione e alert manuale, (3) se il PDF generato ha dimensione <5KB (sintomo di rendering fallito) o >10MB (sintomo di errore con stack trace embedded), alert e blocco invio. Tempo medio generazione: 3-4 minuti per dati 1 mese di traffico (~12K trace), dominato dalla query Langfuse aggregata per feature. Output salvato in /var/log/portfolio/reports/cfo_YYYY_MM.{pdf,xlsx}, retention 24 mesi (audit fiscale italiano richiede 10 anni, gli archivi vecchi vanno su S3-compat con lifecycle policy).
Sezione 9 - Esempio Federico ml-service portfolio production deploy
Lo stack completo che ho deployato sul mio ml-service portfolio gira da fine Aprile 2026, con metriche stabili per 30+ giorni. Il setup è replicabile end-to-end con il codice degli snippet sopra. Configurazione: Ollama on-prem (modello llama3.1:8b) sul VPS Hetzner, Langfuse self-hosted con Postgres backend (zero costo cloud, privacy-first), Prometheus + Grafana per metriche custom, Alertmanager con webhook Slack + PagerDuty (free tier).
I numeri reali a 30 giorni: 12.4K request totali (~400/giorno), latency p50 320ms, p95 720ms, p99 1.8s (cold start incluso), cost totale €23.40 (€0.78/giorno medio), top user 8% del cost (140 query in un mese, €1.87 generato). Cost SLA target €30/mese, attualmente sotto del 22%, sustainable per scale 5x prima di toccare i limiti hardware del VPS Hetzner CCX23. Tre issue rilevate e risolte via observability:
Issue 1 - Cold start timeout. Dopo ogni systemctl restart ml-service il primo request causava timeout client (Ollama caricava il modello in memoria, 8-12 secondi). Diagnosi: heatmap latency mostrava bimodal distribution con cluster a 10s. Fix: ExecStartPost nel systemd unit esegue una warmup query a /health che forza il load del modello prima che il servizio diventi raggiungibile esterno.
Issue 2 - Ollama OOM su concurrency. 3 request concurrent con context >2K token causavano OOM con kill del processo Ollama. Diagnosi: alert error_rate spike correlato a request rate spike, span ollama_call con error cuda_oom. Fix: semaforo asyncio max=2 nel wrapper Ollama provider, request 3+ in coda con timeout 30s.
Issue 3 - Trace mancanti su error path. Eccezioni Python non instrumentate generavano span senza error attribute, statistiche errore sottostimate del 30%. Diagnosi: discrepanza fra Prometheus error_rate (8%) e Langfuse error_rate (5.5%). Fix: context manager wrap con try/except che setta sempre span.set_status(StatusCode.ERROR) e span.record_exception() prima di re-raise.
Tempo totale debug 3 issue: ~6 ore distribuite su 2 settimane. Senza observability sarebbe stato 3-5x. Costo infrastruttura totale del setup observability: €0/mese (tutto self-hosted sul VPS Hetzner già presente, marginale di CPU/RAM).
FAQ
Instrumentazione agent: meglio Langfuse SDK nativo o OpenTelemetry GenAI semantic conventions per future-proofing?
Strategia ibrida che applico nel ml-service portfolio: OTel come strato base + Langfuse come destinazione. OTel GenAI semantic conventions (gen_ai.system, gen_ai.request.model, gen_ai.usage.input_tokens) sono lo standard CNCF in adozione 2025-2026, ti danno portabilita verso qualsiasi backend (Jaeger, Tempo, Datadog, Honeycomb). Langfuse SDK ti da feature LLM-specifiche fuori dal perimetro OTel (prompt versioning, evaluation, dataset management) che oggi nessuno standard copre. Implementazione concreta: uso opentelemetry-sdk + opentelemetry-instrumentation-langchain per generare span con attributi GenAI standard, poi configuro un OTel processor custom che spedisce gli stessi span anche al collector Langfuse via OTLP exporter (Langfuse 2.x supporta OTLP nativo). Cost: zero overhead aggiuntivo, mantieni opzione di migrare via da Langfuse senza riscrivere instrument code. Quando NON usare OTel: prototype rapido o team senza esperienza OTel, parti da Langfuse SDK puro e aggiungi OTel quando arrivi in produzione.
Cost SLA con circuit breaker: come blocco un user che sta sforando il budget mensile senza interrompere bruscamente le sessioni in corso?
Pattern soft-cap + hard-cap a doppio livello che ho implementato per il tier Pro/Enterprise. Soft-cap a 80% del budget mensile (es. €40 su tier €50): l'app continua a rispondere ma logga warning, invia email all'utente con riassunto consumo, attiva fallback a modello cheaper (cache aggressiva o risposta cached) per ridurre cost/query del 70%. Hard-cap a 100%: rifiuto nuove richieste con HTTP 429 e messaggio educato 'Hai raggiunto il limite mensile, upgrade tier o attendi ciclo successivo'. Sessioni in corso (request già in esecuzione) vengono completate, niente kill brutale. Implementazione: Redis hash per user_id con campi {budget_cents, spent_cents, last_reset}, decremento atomico via INCRBY a inizio request (pessimistic), se spent > budget rifiuto. Reset notturno il primo del mese via cron. Edge case importante: sovrastima a inizio request basata su token preventivati (max_tokens config), refund della differenza a fine request quando conosco i token reali consumati. Così evito di sforare anche se 10 request partono contemporaneamente in race condition.
Deployment checklist 30 item: quali sono i 5 più critici che salvano da rollback in produzione?
I 5 critici basati sul mio post-mortem reale. (1) Eval test set passa con threshold definita prima del deploy (success >90%, latency p95 <800ms, cost/query <€0.05) eseguito sull'ambiente staging che mirror produzione (stesso modello Ollama, stesso prompt version, stesso retrieval index). Skip questo step è la causa #1 di regression silenziose. (2) Smoke test post-deploy con 5 query canary entro 60 secondi dall'avvio del servizio: se anche solo 1 fallisce, rollback automatico via systemctl. (3) Configurazione Langfuse separata dev/staging/prod (project_id distinti) per evitare contaminazione dati eval con traffico produzione. (4) Cost cap globale impostato a 2x del budget medio prima di andare live (cintura di sicurezza per bug che amplificano cost 10x), monitorato con alert real-time. (5) Rollback runbook documentato e testato (procedura step-by-step per tornare alla versione precedente in <5 minuti, include rollback prompt template versions e modello Ollama). Gli altri 25 item sono importanti ma non bloccanti, questi 5 sono no-go criteria assoluti.
Smoke test post-deploy: quali assertion eseguo nei primi 60 secondi e come gestisco i falsi positivi?
Suite di 5 smoke test con tolleranza calibrata che ho automatizzato nel ml-service. (1) Health endpoint risponde 200 entro 5 secondi. (2) Trace flow end-to-end con query semplice nota, aspetto risposta non vuota e Langfuse trace creata (verifico via Langfuse API che il trace_id esista). (3) Cost calculation funziona: la trace ha campo cost_cents popolato non null (catch dei silent failure dove pricing config è rotto). (4) Embedding service raggiungibile: query nota su /internal/ai/similar restituisce risultati ranked. (5) Latency p95 sui 5 test è <2x il baseline storico. Falsi positivi: il primo run dopo cold start ha sempre latency 2-3x normale (model loading), quindi escludo il primo run dalle assertion latency, considero solo il 2-5. Se 4/5 test passano = warning ma not block, se <4/5 passano = automatic rollback. Implementazione: script Python con asyncio, eseguito dal systemd ExecStartPost. Tempo totale 45-90 secondi a seconda del cold start.
Dashboard Grafana production overview: quali 6 panel sono essenziali per una review giornaliera di 5 minuti?
Mio dashboard 'AI Production Pulse' con 6 panel essenziali. (1) Single Stat Latency p99 ultime 24h con threshold colorate (<500ms verde, 500-1000 giallo, >1000 rosso) e sparkline trend 7 giorni. (2) Single Stat Cost spent oggi vs budget giornaliero (€20/giorno target), gauge con percentuale. (3) Time series Request rate per minuto split per endpoint (/recommend, /chat, /similar), individui spike e drop. (4) Time series Error rate % per endpoint con annotation per i deploy (banda verticale). (5) Heatmap latency distribution: x=tempo, y=latency bucket, color=request count. Visualizza istantaneamente long tail e bimodal distribution che il p99 nasconde. (6) Tabella Top 10 user per cost ultime 24h con breakdown per feature (recommend/chat/similar). Tutto sopra mi dice in 5 minuti se il sistema è sano, dove c'è anomalia, chi sta consumando. Esporto JSON definito in dashboard/llm-pulse.json del repo, importabile in Grafana qualsiasi.
CFO cost report mensile: quale formato vince con il finance team senza essere troppo tecnico?
Formato 1 pagina PDF + spreadsheet allegato che ho rodato. PDF struttura: (1) Headline number 'Spent in May: €847 / Budget €1000 = 85%' con verde/giallo/rosso. (2) Top 3 cost driver con stacked bar. (3) Trend mese su mese: bar chart 6 mesi. (4) Cost per user revenue: 'Average user paga €15 tier Pro, ci costa €2 in LLM = 87% margin', metriche unit economics che il CFO vuole. (5) Anomalies & remediation con link a runbook. (6) Forecast next month basato su trend lineare + stagionalita. Spreadsheet allegato XLSX con dettaglio per giorno, per user, per endpoint, per modello, esportato da Langfuse API + arricchito con dati billing. Generato automaticamente il primo del mese via Python script (pandas + matplotlib + WeasyPrint per PDF), email a finance@ con CC me. Tempo manutenzione: 30 minuti/mese per controllare narrative section. Vincente perché il CFO non deve interpretare query SQL, vede subito la salute economica del prodotto AI.
Esempio Federico: come hai instrumentato il ml-service portfolio in produzione e quali metriche stai osservando dopo 30 giorni?
Stack reale del mio ml-service portfolio Aprile-Maggio 2026. Instrumentation: opentelemetry-sdk Python + opentelemetry-instrumentation-fastapi + langfuse 2.x SDK + prometheus-client per metriche custom. Span generati: 1 root span per request HTTP, child span per ogni step (retrieve, rerank, ollama_call, post_process). Attributi: gen_ai.request.model='llama3.1:8b', gen_ai.usage.input_tokens, gen_ai.usage.output_tokens, ml.retrieval.top_k=5, ml.rerank.threshold=0.7. Trace destination: Langfuse self-hosted su VPS Hetzner con Postgres backend (privacy-first, zero cloud cost). Metriche Prometheus aggiuntive per Grafana: histogram latency_seconds per endpoint, counter cost_cents_total per user_id, gauge active_sessions. Risultati 30 giorni: 12.4K request total (~400/giorno), latency p50=320ms p95=720ms p99=1.8s, cost totale €23.40 (€0.78/giorno), top user 8% del cost. Issue rilevati e risolti: cold start dopo systemd restart causava timeout primo request (fix con warmup query in ExecStartPost), Ollama OOM su 3 request concurrent (fix con semaforo concorrenza max=2), trace mancanti su error path (fix con context manager wrap try/except). Cost SLA target €30/mese, attualmente sotto del 22%, sustainable per scale 5x. Tutto open source, replicabile, zero dipendenze cloud.
Conclusione - Ricapitolazione Serie 27 LLM Observability Production
Questo episodio chiude la Serie 27 - LLM Observability Production. Nei 9 episodi abbiamo costruito uno stack completo, dal "perché serve" al "come deployarlo in produzione con SLA cost garantito":
- EP01 - LLM observability crisis: silent failures. Il logging non basta: prompt template che hallucinava, token spesi 5x del previsto, latency degradata 50%. Tre categorie di tooling: request tracing, profiling, metrics aggregation.
- EP02 - Langfuse setup: self-hosted vs managed. 50K event/month free tier, self-host su VPS €0/mese, prompt versioning, evaluation integration, cost auto-calculation per modello.
- EP03 - Phoenix notebook-first observability. In-memory evaluation, embedding visualization, A/B hypothesis testing in Jupyter. Phoenix per esplorazione, Langfuse per produzione.
- EP04 - Helicone multi-provider. Proxy layer per unificare trace cross-provider (cloud APIs + local Ollama), cost comparison, cache tracking. Custom middleware per modelli locali fuori scope Helicone.
- EP05 - Span design semantico. 1 span = 1 unit semantica, naming convention, attributi GenAI, hierarchy parent-child, sampling 1% al scale.
- EP06 - Cost tracking per component. Breakdown retrieval / agent step / generation, cost per user, FinOps export, anomaly detection, chargeback model per team.
- EP07 - Production alerting. Latency spike, error rate, cost anomaly con baseline rolling + z-score, token budget per user. PagerDuty critical, Slack warning, false positive cost.
- EP08 - Debug trace analysis. Trace diff con DeepDiff, error pattern clustering DBSCAN, embedding similarity per trace, decision tree root-cause, replay framework deterministico.
- EP09 - End-to-end workflow (questo episodio). Instrumentation LangChain con OTel + Langfuse, deployment checklist 30-item, cost SLA con circuit breaker, dashboard Grafana, smoke test post-deploy, CFO cost report mensile.
Il filo conduttore è che observability LLM non è un tool, è una disciplina con 5 layer (instrumentation, storage, alerting, debug, governance). Adottarla costa effort upfront ma diventa il moltiplicatore di velocita per ogni decisione successiva: deploy senza paura perché lo smoke test ti rolla back, cost prevedibile perché il circuit breaker ti ferma, root cause in 5 minuti perché hai trace structured e clustering pattern. Il mio ml-service portfolio gira a €23/mese con osservabilità completa, zero dipendenze cloud, replicabile end-to-end.
Serie correlate per estendere lo stack:
- Serie 22 - Self-Hosted LLM Stack: Ollama, vLLM, llama.cpp, deployment GPU/CPU, quantization, model selection. Complementare per i layer di runtime sotto l'observability.
- Serie 23 - LLM Cost Optimization: caching aggressive, prompt compression, semantic cache, model routing per cost. Estende EP06 e EP07 di questa serie con tecniche di riduzione cost a parita di qualita.
- Serie 33 - AI Governance & Compliance: audit trail, GDPR per LLM, EU AI Act, model card, dataset card, post-mortem framework. Estende il layer governance verso compliance regolatoria.
Prossimo episodio: Fine Serie 27. Bonus disponibile nel repo: template Langfuse configuration completa + Python SDK ready-to-use per agent + 3 eval metric (latency, cost, success). Prossima serie in calendario: Serie 28 con focus su un nuovo tema AI engineering. Grazie per aver seguito tutta la Serie 27, le tue osservazioni e i tuoi commenti sono la prima fonte per migliorare le prossime serie.

Commenti
Caricamento commenti...
Accedi per commentare