Tüm rehberler
RehberYapay Zeka12 · Uygulama

AI Observability
& OpenTelemetry.

LLM uygulamasını üretimde izle. OpenTelemetry ile trace ve span, token maliyeti ve latency metrikleri, Langfuse ile prompt versiyonlama. Drift tespiti ve kalite monitörü.

00 Neden Monitoring?

Üretimdeki bir LLM uygulamasını izlemezsen, sorunları ancak kullanıcılar şikayet ettikten sonra fark edersin.

Klasik bir web servisinde sorun nettir: HTTP 500 döner, log satırı yazar, alert tetiklenir. LLM uygulamalarında ise tablo çok farklıdır. Model yanıt verir — yanıt 200 OK döner — ama içerik tamamen yanlış olabilir. Hallüsinasyon üretir, prompt injection'a düşer ya da kullanıcıya tutarsız sonuçlar sunar. Tüm bunlar altyapı katmanında tamamen "sağlıklı" görünür.

Üretim LLM monitoring'in üç katmanı vardır: altyapı izleme (CPU, bellek, GPU kullanımı), uygulama izleme (latency, throughput, hata oranı) ve LLM kalite izleme (hallüsinasyon oranı, token kullanımı, kullanıcı memnuniyeti). İlk iki katman DevOps'tan gelir; üçüncüsü LLM'e özgüdür ve çoğu ekip bu katmanı tamamen atlar.

LLM'e Özgü Sorunlar

Klasik yazılım monitoring araçları şu soruları yanıtlayamaz:

Token tükenmesiContext window dolunca model bağlamı kaybeder — hata vermez, cevap kalitesi sessizce düşer.
Maliyet artışıPrompt uzunluğu arttıkça token maliyeti katlanır. Aylık $50 olan servis birkaç değişiklikle $2000'e çıkabilir.
Latency bozulmasıModel sağlayıcısında kapasiteden kaynaklı yavaşlama; kullanıcı timeout'a düşer.
Prompt driftKullanıcı davranışı zamanla değişir; eski prompt şablonları yeni sorgular için yetersiz kalabilir.
Hallüsinasyon oranıBelirli konu kümeleri için model yanlış yanıt vermeye başlayabilir; ancak bu kural-tabanlı testlerle yakalanamaz.
NOT

"You can't improve what you can't measure." — Peter Drucker. LLM sistemlerinde bu söz iki kat daha geçerlidir: ölçmediğin şeyi ne zaman bozulduğunu bile fark edemezsin.

Monitoring Katmanları

01 Infra Layer   → CPU / GPU kullanımı, bellek, disk, ağ
02 App Layer    → HTTP latency, hata oranı, RPS, timeout
03 LLM Layer    → token sayısı, maliyet, prompt/completion ratio
04 Quality Layer → hallüsinasyon skoru, kullanıcı geri bildirimi, drift

Bu rehber 3. ve 4. katmanlara odaklanır; araç olarak OpenTelemetry, Langfuse, Prometheus ve Grafana kullanır.

01 OpenTelemetry Temelleri

OpenTelemetry, vendor-neutral bir observability standardıdır; aynı enstrümantasyon koduyla farklı backend'lere veri gönderebilirsin.

OpenTelemetry (OTel), CNCF bünyesinde geliştirilen açık kaynak bir observability çerçevesidir. Datadog, Jaeger, Grafana Tempo veya Langfuse gibi birbirinden farklı backend'lere aynı SDK ile bağlanabilirsin; vendor lock-in yoktur.

Üç Pillar: Traces, Metrics, Logs

PillarTanımLLM Karşılığı
TracesBir isteğin tüm sistemden geçiş yolu; zaman damgalı adımlarKullanıcı sorusu → retrieval → LLM çağrısı → yanıt
MetricsSayısal, zaman serisi ölçümler; toplam, ortalama, percentilToken/sn, maliyet/istek, P95 latency
LogsYapısal veya serbest metin olay kaydıPrompt/completion içerikleri, hata mesajları

Temel Kavramlar

TraceUçtan uca bir isteğin tüm yolculuğu. Benzersiz bir trace_id taşır.
SpanTrace içindeki tek bir işlem birimi. Başlangıç/bitiş zamanı, attributes ve events içerir.
Parent / Child SpanSpan'ler ağaç oluşturur: HTTP handler span → LLM call span → token count span.
Context PropagationServisler arası trace kimliğini aktarır. HTTP header W3C TraceContext formatı kullanılır.
ExporterToplanan veriyi dış backend'e gönderen bileşen (OTLP, Jaeger, Prometheus exporter).
CollectorOpsiyonel ara katman: veri toplar, dönüştürür ve birden fazla backend'e iletir.

Kurulum

terminal
# OTel Python SDK ve OTLP exporter
pip install opentelemetry-sdk \
            opentelemetry-exporter-otlp \
            opentelemetry-instrumentation-fastapi \
            opentelemetry-instrumentation-httpx
otel_setup.py
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

def setup_tracer(service_name: str) -> trace.Tracer:
    resource = Resource(attributes={"service.name": service_name})

    provider = TracerProvider(resource=resource)

    # OTLP gRPC ile Jaeger / Grafana Tempo'ya gönder
    exporter = OTLPSpanExporter(endpoint="http://localhost:4317")
    provider.add_span_processor(BatchSpanProcessor(exporter))

    trace.set_tracer_provider(provider)
    return trace.get_tracer(service_name)

tracer = setup_tracer("llm-service")

02 LLM Trace Yapısı

Her LLM isteği için zengin metadata içeren span ağacı oluşturmak, sorunları dakikalar içinde izole etmeni sağlar.

Tipik bir RAG tabanlı LLM uygulamasında bir kullanıcı isteği şu span ağacını oluşturur:

 http_request           (root span, ~850ms)
├─ chain.run              (~820ms)
│  ├─ retrieval.search     (~120ms) — vektör DB sorgusu
│  ├─ reranker.score       (~80ms)  — chunk sıralama
│  └─ llm.chat_completion  (~600ms) — OpenAI API
│     ├─ prompt_tokens: 1240
│     └─ completion_tokens: 312
└─ response.serialize    (~10ms)

Span Attribute'ları

Her span'e kaydetmen gereken minimum attribute seti:

AttributeTipAçıklama
llm.modelstringKullanılan model adı (gpt-4o, claude-3-5-sonnet)
llm.prompt_tokensintGiriş token sayısı
llm.completion_tokensintÇıkış token sayısı
llm.temperaturefloatÖrnekleme sıcaklığı
llm.latency_msfloatModel yanıt süresi
llm.finish_reasonstringstop / length / content_filter

Manuel Span Oluşturma

llm_tracer.py
import time
from opentelemetry import trace
from openai import OpenAI

tracer = trace.get_tracer("llm-service")
client = OpenAI()

def traced_chat(
    messages: list[dict],
    model: str = "gpt-4o",
    temperature: float = 0.7,
) -> str:
    with tracer.start_as_current_span("llm.chat_completion") as span:
        span.set_attribute("llm.model", model)
        span.set_attribute("llm.temperature", temperature)
        span.set_attribute("llm.message_count", len(messages))

        t0 = time.perf_counter()
        try:
            response = client.chat.completions.create(
                model=model,
                messages=messages,
                temperature=temperature,
            )
            latency_ms = (time.perf_counter() - t0) * 1000

            usage = response.usage
            span.set_attribute("llm.prompt_tokens", usage.prompt_tokens)
            span.set_attribute("llm.completion_tokens", usage.completion_tokens)
            span.set_attribute("llm.total_tokens", usage.total_tokens)
            span.set_attribute("llm.latency_ms", round(latency_ms, 2))
            span.set_attribute("llm.finish_reason", response.choices[0].finish_reason)

            span.set_status(trace.Status(trace.StatusCode.OK))
            return response.choices[0].message.content

        except Exception as e:
            span.set_status(trace.Status(trace.StatusCode.ERROR, str(e)))
            span.record_exception(e)
            raise
NOT

Prompt ve completion içeriklerini span attribute olarak kaydetmek hassas veri içerebilir. Üretimde bu alanlar için PII scrubbing veya opt-in mekanizması kur; GDPR kapsamında kullanıcı verisini loglamamak zorunlu olabilir.

RAG Chain için Bütünleşik Trace

rag_chain.py
from opentelemetry import trace
from llm_tracer import traced_chat

tracer = trace.get_tracer("llm-service")

def rag_query(question: str, vector_db, reranker) -> str:
    with tracer.start_as_current_span("chain.run") as chain_span:
        chain_span.set_attribute("chain.question", question[:200])

        # Retrieval span
        with tracer.start_as_current_span("retrieval.search") as ret_span:
            chunks = vector_db.similarity_search(question, k=10)
            ret_span.set_attribute("retrieval.num_results", len(chunks))

        # Reranking span
        with tracer.start_as_current_span("reranker.score") as rer_span:
            top_chunks = reranker.rerank(question, chunks, top_n=3)
            rer_span.set_attribute("reranker.top_n", len(top_chunks))

        context = "\n\n".join([c.page_content for c in top_chunks])
        messages = [
            {"role": "system", "content": "Yalnızca verilen bağlamı kullan."},
            {"role": "user", "content": f"Bağlam:\n{context}\n\nSoru: {question}"},
        ]

        return traced_chat(messages)

03 Token Maliyet Takibi

Token başına maliyet küçük görünür; ancak ölçek büyüdükçe izlenmemiş kullanım faturanı katlar.

OpenAI API'si prompt token ve completion token için ayrı ayrı ücretlendirir. Completion token genellikle prompt token'dan 2–4 kat daha pahalıdır. Bu asimetriyi göz ardı eden sistemler kısa sorular için bile yüksek maliyet üretebilir.

Güncel Fiyat Modeli (Nisan 2025)

ModelPrompt ($/1M token)Completion ($/1M token)
gpt-4o$2.50$10.00
gpt-4o-mini$0.15$0.60
gpt-4-turbo$10.00$30.00
gpt-3.5-turbo$0.50$1.50
claude-3-5-sonnet$3.00$15.00

Tiktoken ile Önceden Sayım

API çağrısından önce tiktoken ile token sayısını hesaplamak, bütçe aşımını engellemek için önemlidir. Özellikle kullanıcı tarafından kontrol edilen içeriklerde bu şart olmalıdır.

cost_tracker.py
import tiktoken
from dataclasses import dataclass
from opentelemetry import metrics

# Metrik meter (Prometheus uyumlu)
meter = metrics.get_meter("llm-cost-tracker")
cost_counter = meter.create_counter(
    "llm_cost_usd_total",
    description="Toplam LLM maliyeti (USD)",
    unit="USD",
)
token_histogram = meter.create_histogram(
    "llm_tokens_per_request",
    description="İstek başına token dağılımı",
)

# Model başına fiyat tablosu ($/1M token)
PRICE_TABLE: dict[str, dict] = {
    "gpt-4o":          {"prompt": 2.50,  "completion": 10.00},
    "gpt-4o-mini":     {"prompt": 0.15,  "completion": 0.60},
    "gpt-4-turbo":     {"prompt": 10.00, "completion": 30.00},
    "gpt-3.5-turbo":   {"prompt": 0.50,  "completion": 1.50},
}

@dataclass
class CostResult:
    prompt_tokens: int
    completion_tokens: int
    total_tokens: int
    cost_usd: float

def count_tokens(text: str, model: str = "gpt-4o") -> int:
    """API çağrısı yapmadan token sayısını hesapla."""
    try:
        enc = tiktoken.encoding_for_model(model)
    except KeyError:
        enc = tiktoken.get_encoding("cl100k_base")
    return len(enc.encode(text))

def calculate_cost(
    prompt_tokens: int,
    completion_tokens: int,
    model: str,
) -> CostResult:
    prices = PRICE_TABLE.get(model, {"prompt": 0, "completion": 0})
    cost = (
        (prompt_tokens     / 1_000_000) * prices["prompt"]
      + (completion_tokens / 1_000_000) * prices["completion"]
    )
    total = prompt_tokens + completion_tokens

    # OTel metrik kaydet
    labels = {"model": model}
    cost_counter.add(cost, labels)
    token_histogram.record(total, labels)

    return CostResult(
        prompt_tokens=prompt_tokens,
        completion_tokens=completion_tokens,
        total_tokens=total,
        cost_usd=round(cost, 8),
    )

# Kullanım örneği
if __name__ == "__main__":
    prompt = "Python'da bir async web scraper nasıl yazılır?"
    n = count_tokens(prompt)
    print(f"Prompt token sayısı: {n}")

    result = calculate_cost(n, 250, "gpt-4o")
    print(f"Tahmini maliyet: ${result.cost_usd:.6f}")
DİKKAT

Tiktoken sayımı API'nin gerçek sayımından 1–3 token sapabilir. Özellikle sistem mesajları ve özel tokenlar için. Kesin bütçe hesaplamalarında API yanıtından gelen usage.total_tokens değerini kullan.

04 Langfuse ile Prompt Versiyonlama

Prompt değişikliklerini git gibi versiyonla; hangi prompt'un daha iyi sonuç ürettiğini veriyle kanıtla.

Langfuse, LLM uygulamaları için açık kaynak bir observability ve prompt management platformudur. Self-hosted veya cloud olarak kullanılabilir. Trace ve span'leri görsel arayüzde incelemek, prompt versiyonlarını kıyaslamak ve production'daki maliyet/kalite metriklerini takip etmek için kullanılır.

Kurulum

terminal
pip install langfuse openai

# .env dosyasına ekle
LANGFUSE_PUBLIC_KEY=pk-lf-...
LANGFUSE_SECRET_KEY=sk-lf-...
LANGFUSE_HOST=https://cloud.langfuse.com  # veya self-hosted URL

Trace Gönderme

langfuse_trace.py
from langfuse import Langfuse
from langfuse.decorators import langfuse_context, observe
from openai import OpenAI
import os

lf = Langfuse(
    public_key=os.environ["LANGFUSE_PUBLIC_KEY"],
    secret_key=os.environ["LANGFUSE_SECRET_KEY"],
    host=os.environ.get("LANGFUSE_HOST", "https://cloud.langfuse.com"),
)
client = OpenAI()

# @observe dekoratörü ile otomatik trace
@observe()
def answer_question(question: str, user_id: str) -> str:
    # Prompt versiyonunu Langfuse'dan çek
    prompt_obj = lf.get_prompt("rag-system-prompt", label="production")
    system_msg = prompt_obj.compile(language="tr")

    langfuse_context.update_current_trace(
        name="answer_question",
        user_id=user_id,
        tags=["rag", "production"],
    )

    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[
            {"role": "system", "content": system_msg},
            {"role": "user", "content": question},
        ],
    )

    answer = response.choices[0].message.content

    # Kalite skoru kaydet
    langfuse_context.score_current_trace(
        name="relevance",
        value=1.0,  # kullanıcı geri bildirimi ile güncelle
        comment="auto-score",
    )
    return answer

Prompt Versiyonu Kaydetme

prompt_management.py
# Yeni prompt versiyonu oluştur
lf.create_prompt(
    name="rag-system-prompt",
    prompt="""Sen  dilinde yanıt veren yardımcı bir asistansın.
Yalnızca aşağıdaki bağlam bilgisini kullan.
Bağlamda yoksa 'Bilmiyorum' de.

Bağlam:
""",
    labels=["staging"],  # önce staging'e gönder
    config={"model": "gpt-4o", "temperature": 0.3},
)

# A/B test için ikinci versiyon
lf.create_prompt(
    name="rag-system-prompt",
    prompt="""Görevin:  dilinde kısa ve öz yanıtlar vermek.
Sadece belirtilen kaynakları kullan. Kaynak yoksa reddet.

Kaynaklar:
""",
    labels=["experiment-v2"],
)
NOT

Langfuse'un prompt versiyonlama sistemi semantic versioning kullanmaz; bunun yerine "label" tabanlı çalışır. production, staging, latest gibi etiketler kullanarak hangi versiyonun canlıda olduğunu kolayca kontrol edebilirsin.

05 Latency ve Throughput Metrikleri

Kullanıcı deneyimini etkileyen üç kritik latency metriği ve bunları Prometheus'a nasıl aktaracağın.

Temel Latency Kavramları

TTFTTime-to-First-Token: kullanıcının ilk token'ı görmesi için geçen süre. Akış (streaming) olmadan bu toplam latency'dir. Hedef: <1s.
TPOTTime-Per-Output-Token: her ek token için geçen süre. Akışlı yanıtlarda "hız" hissi bunu belirler. Hedef: <30ms/token.
Total LatencyTTFT + (TPOT × completion_tokens). Bütün yanıtın teslim süresi.
ThroughputSaniyedeki toplam token üretimi (tokens/s). Sistemin kapasitesini ölçer.
P95 / P99Kullanıcıların %95 / %99'unun deneyimlediği maksimum latency. SLA'lar bu percentil üzerinden tanımlanır.

Prometheus Metrik Tanımı

metrics.py
from prometheus_client import (
    Histogram, Counter, Gauge, start_http_server
)
import time, threading

# Histogram: latency dağılımı (bucket sınırları ms cinsinden)
LLM_LATENCY = Histogram(
    "llm_request_latency_ms",
    "LLM isteği latency dağılımı",
    labelnames=["model", "endpoint"],
    buckets=[50, 100, 250, 500, 1000, 2000, 5000, 10000],
)
TTFT_HISTOGRAM = Histogram(
    "llm_ttft_ms",
    "Time-to-first-token (ms)",
    labelnames=["model"],
    buckets=[100, 250, 500, 750, 1000, 1500, 2000],
)
TOKEN_THROUGHPUT = Gauge(
    "llm_tokens_per_second",
    "Anlık token throughput",
    labelnames=["model"],
)
REQUEST_COUNTER = Counter(
    "llm_requests_total",
    "Toplam LLM istek sayısı",
    labelnames=["model", "status"],
)

def record_latency(model: str, endpoint: str, latency_ms: float, status: str):
    LLM_LATENCY.labels(model=model, endpoint=endpoint).observe(latency_ms)
    REQUEST_COUNTER.labels(model=model, status=status).inc()

def record_streaming_metrics(model: str, ttft_ms: float, total_tokens: int, total_ms: float):
    TTFT_HISTOGRAM.labels(model=model).observe(ttft_ms)
    if total_ms > 0:
        throughput = (total_tokens / total_ms) * 1000  # tokens/s
        TOKEN_THROUGHPUT.labels(model=model).set(throughput)

# Prometheus /metrics endpoint'i başlat
def start_metrics_server(port: int = 8001):
    threading.Thread(
        target=start_http_server,
        args=(port,),
        daemon=True,
    ).start()
    print(f"Prometheus metrics: http://localhost:{port}/metrics")

SLA Tanımı

NOT

Örnek SLA kuralı: P95 latency < 2000ms, P99 latency < 5000ms, error rate < 0.1%. Bu değerleri Grafana'da alert kuralına dönüştür; ihlal olduğunda Slack veya PagerDuty tetiklensin.

06 Drift Tespiti

Input dağılımı kayarsa model kalitesi sessizce düşer; istatistiksel testlerle bunu erken yakala.

Drift, sistemin eğitildiği veya optimize edildiği veri dağılımının zamanla değişmesidir. LLM sistemlerinde üç tür drift önemlidir:

Input DriftKullanıcı sorularının konusu, dili veya uzunluğu değişiyor. Eski prompt şablonları yetersiz kalır.
Output DriftModelin yanıt kalitesi düşüyor: kısalıyor, tutarsızlaşıyor veya belirli konu kümelerinde bozuluyor.
Embedding DriftRAG vektör veritabanı güncellendikçe belge dağılımı değişiyor; retrieval kalitesi bozuluyor.

İstatistiksel Testler

TestNe ÖlçerNe Zaman Kullan
Kolmogorov-Smirnov (KS)İki dağılımın farklı olup olmadığıToken uzunluğu, latency dağılımı
Jensen-Shannon DivergenceDağılımlar arası mesafe (0–1)Embedding dağılımı, konu dağılımı
Population Stability IndexÖzellik dağılımı değişimiÜretimde model feature monitoring
Chi-SquareKategorik dağılım değişimiKonu sınıfı dağılımı

Embedding Drift Tespiti

drift_detector.py
import numpy as np
from scipy import stats
from scipy.spatial.distance import jensenshannon
from openai import OpenAI
from dataclasses import dataclass, field
import json, pathlib

client = OpenAI()

@dataclass
class DriftReport:
    ks_statistic: float
    ks_pvalue: float
    js_divergence: float
    is_drifted: bool
    alert_message: str = ""

def embed_texts(texts: list[str]) -> np.ndarray:
    response = client.embeddings.create(
        input=texts,
        model="text-embedding-3-small",
    )
    return np.array([e.embedding for e in response.data])

def detect_embedding_drift(
    baseline_texts: list[str],
    current_texts: list[str],
    ks_alpha: float = 0.05,
    js_threshold: float = 0.1,
) -> DriftReport:
    """Baseline ve güncel embedding'ler arasında drift tespit et."""

    baseline_emb = embed_texts(baseline_texts)
    current_emb  = embed_texts(current_texts)

    # PCA ile boyut indir, ilk komponentin dağılımını karşılaştır
    from sklearn.decomposition import PCA
    pca = PCA(n_components=1)
    baseline_proj = pca.fit_transform(baseline_emb).flatten()
    current_proj  = pca.transform(current_emb).flatten()

    # KS testi: dağılımlar aynı mı?
    ks_stat, ks_p = stats.ks_2samp(baseline_proj, current_proj)

    # Jensen-Shannon divergence (histogram karşılaştırması)
    bins = np.linspace(
        min(baseline_proj.min(), current_proj.min()),
        max(baseline_proj.max(), current_proj.max()),
        30,
    )
    hist_a, _ = np.histogram(baseline_proj, bins=bins, density=True)
    hist_b, _ = np.histogram(current_proj,  bins=bins, density=True)

    # Sıfırları önle
    hist_a = hist_a + 1e-10
    hist_b = hist_b + 1e-10
    js_div = float(jensenshannon(hist_a, hist_b))

    is_drifted = (ks_p < ks_alpha) or (js_div > js_threshold)
    msg = ""
    if is_drifted:
        msg = (f"DRIFT UYARISI: KS={ks_stat:.3f} (p={ks_p:.4f}), "
               f"JS={js_div:.3f}. Prompt şablonunu güncelle.")

    return DriftReport(
        ks_statistic=round(ks_stat, 4),
        ks_pvalue=round(ks_p, 4),
        js_divergence=round(js_div, 4),
        is_drifted=is_drifted,
        alert_message=msg,
    )

# Kullanım örneği
if __name__ == "__main__":
    baseline = [
        "Python nedir?", "Liste nasıl oluşturulur?",
        "Fonksiyon tanımla", "Döngü örneği ver",
    ]
    current = [
        "Kubernetes pod nedir?", "Helm chart nasıl yazılır?",
        "Ingress controller kur", "Service mesh nedir?",
    ]
    report = detect_embedding_drift(baseline, current)
    print(report)
DİKKAT

Drift tespiti için baseline olarak son 7 günlük pencereyi al; mevsimsel veya haftalık doğal değişimleri yok saymak için Bonferroni düzeltmesi uygula. Düşük trafik dönemlerinde küçük örneklem boyutu yanlış alarm üretir.

07 Alerting ve Dashboard

Prometheus metriklerini Grafana'da görselleştir, kritik eşikler için Slack'e anlık alert gönder.

Veri toplamak yetmez; actionable uyarılar ve net dashboard'lar olmadan monitoring değeri yaratmaz. Bu bölümde Grafana dashboard JSON şablonu ve Slack webhook entegrasyonu gösterilmektedir.

Alert Kuralları

MetrikEşikŞiddet
P95 Latency> 2000ms (5 dakika)warning
P99 Latency> 5000ms (1 dakika)critical
Error Rate> 1% (3 dakika)warning
Error Rate> 5% (1 dakika)critical
Saatlik Maliyet> $50warning
Günlük Maliyet> $500critical
JS Drift> 0.15warning

Slack Webhook Alert

alerting.py
import httpx, json
from datetime import datetime
from enum import Enum

class Severity(Enum):
    INFO     = "#36a64f"  # yeşil
    WARNING  = "#ffa500"  # turuncu
    CRITICAL = "#ff0000"  # kırmızı

async def send_slack_alert(
    webhook_url: str,
    title: str,
    message: str,
    severity: Severity = Severity.WARNING,
    fields: dict | None = None,
):
    payload = {
        "attachments": [{
            "color": severity.value,
            "title": f"[LLM Alert] {title}",
            "text": message,
            "fields": [
                {"title": k, "value": str(v), "short": True}
                for k, v in (fields or {}).items()
            ],
            "footer": "LLM Observability",
            "ts": int(datetime.now().timestamp()),
        }]
    }
    async with httpx.AsyncClient() as c:
        resp = await c.post(webhook_url, json=payload)
        resp.raise_for_status()

# Alert örneği: yüksek latency
async def check_latency_sla(p95_ms: float, webhook_url: str):
    if p95_ms > 5000:
        await send_slack_alert(
            webhook_url,
            title="Yüksek Latency",
            message=f"P95 latency SLA ihlali: {p95_ms:.0f}ms (limit: 5000ms)",
            severity=Severity.CRITICAL,
            fields={"P95 Latency": f"{p95_ms:.0f}ms", "SLA Limit": "5000ms"},
        )
    elif p95_ms > 2000:
        await send_slack_alert(
            webhook_url,
            title="Latency Uyarısı",
            message=f"P95 latency yüksek: {p95_ms:.0f}ms",
            severity=Severity.WARNING,
        )

Grafana Dashboard JSON Şablonu

llm-dashboard.json
{
  "title": "LLM Observability",
  "uid": "llm-obs-v1",
  "panels": [
    {
      "title": "P95 Latency (ms)",
      "type": "timeseries",
      "targets": [{
        "expr": "histogram_quantile(0.95, rate(llm_request_latency_ms_bucket[5m]))",
        "legendFormat": ""
      }],
      "alert": {
        "conditions": [{
          "evaluator": {"params": [2000], "type": "gt"},
          "type": "query"
        }]
      }
    },
    {
      "title": "Saatlik Token Maliyeti (USD)",
      "type": "stat",
      "targets": [{
        "expr": "increase(llm_cost_usd_total[1h])"
      }]
    },
    {
      "title": "Hata Oranı (%)",
      "type": "timeseries",
      "targets": [{
        "expr": "100 * rate(llm_requests_total{status='error'}[5m]) / rate(llm_requests_total[5m])"
      }]
    },
    {
      "title": "Token Throughput (tokens/s)",
      "type": "gauge",
      "targets": [{
        "expr": "llm_tokens_per_second"
      }]
    }
  ]
}
NOT

Grafana JSON'unu grafana-cli dashboards import komutu veya Grafana API'si üzerinden (POST /api/dashboards/import) yükleyebilirsin. Alternatif olarak grafana.ini içinde provisioning dizinini tanımlayarak dashboard'ları kod olarak yönetebilirsin.

Docker Compose ile Tam Stack

docker-compose.yml
version: "3.9"
services:
  prometheus:
    image: prom/prometheus:v2.51.0
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml
    ports: ["9090:9090"]

  grafana:
    image: grafana/grafana:10.4.0
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=admin
    volumes:
      - ./grafana/dashboards:/etc/grafana/provisioning/dashboards
    ports: ["3000:3000"]
    depends_on: [prometheus]

  otel-collector:
    image: otel/opentelemetry-collector-contrib:0.98.0
    volumes:
      - ./otel-config.yml:/etc/otelcol-contrib/config.yaml
    ports:
      - "4317:4317"   # gRPC
      - "4318:4318"   # HTTP

  langfuse:
    image: langfuse/langfuse:2
    environment:
      - DATABASE_URL=postgresql://langfuse:langfuse@postgres/langfuse
      - NEXTAUTH_SECRET=secret123
      - SALT=salt123
    ports: ["3010:3000"]
    depends_on: [postgres]

  postgres:
    image: postgres:16-alpine
    environment:
      - POSTGRES_USER=langfuse
      - POSTGRES_PASSWORD=langfuse
      - POSTGRES_DB=langfuse