# ADR-121: OTel GenAI Semantic Conventions

**狀態**：Accepted
**日期**：2026-05-03（台北）
**決策者**：統帥
**範圍**：GenAI span 命名、token attribute、SignOz 上報、現有 telemetry.py 修補
**關聯**：ADR-110（GCP Ollama）、ADR-120（token budget）、INV-4（hardcoded IP）

---

## 背景

OpenTelemetry 已發布 **GenAI Semantic Conventions 1.0**（2025 Q1），定義了 AI/LLM 應用的標準 span 屬性。

現有 `apps/api/src/services/telemetry.py` 的問題：
1. **P0-08**：`telemetry.py:71` hardcoded `if "192.168.0.188" not in endpoint: raise`（阻擋 EwoooC）
2. **不標準**：span 名稱和 attribute 不符合 GenAI Semantic Conventions
3. **token 追蹤缺失**：LLM call 的 prompt/completion token 沒有進 OTel span
4. **OTel 版本**：需要 >=1.25.0 支援 GenAI semconv（INV-5 確認目前 >=1.20.0）

---

## 決策

### D1 — GenAI Span 命名規範

**標準格式**（GenAI semconv）：

```
{gen_ai.operation.name} {gen_ai.system}

例：
  chat ollama         ← Ollama chat 呼叫
  chat openai         ← OpenAI 呼叫（備援）
  chat gemini         ← Gemini 呼叫
  embeddings ollama   ← embedding 呼叫
```

**禁止的命名方式**：
```
# 禁止（舊格式，不符合 semconv）
"llm_call"
"openclaw_decision"
"ai_provider_call"
```

### D2 — GenAI Span Attributes（標準屬性）

每個 LLM call span 必須包含以下 attributes：

```python
# 標準 GenAI attributes（OpenTelemetry GenAI semconv 1.0）
{
    # 系統識別
    "gen_ai.system": "ollama" | "openai" | "gemini" | "nvidia_nim",
    "gen_ai.operation.name": "chat" | "embeddings" | "completion",

    # 請求
    "gen_ai.request.model": "llama3.2",             # 請求的模型名稱
    "gen_ai.request.max_tokens": 4096,
    "gen_ai.request.temperature": 0.7,
    "gen_ai.request.top_p": 0.9,

    # 回應
    "gen_ai.response.model": "llama3.2",            # 實際使用的模型（可能因 fallback 不同）
    "gen_ai.response.finish_reasons": ["stop"],

    # Token 計數（重要：budget 對帳用）
    "gen_ai.usage.prompt_tokens": 512,
    "gen_ai.usage.completion_tokens": 128,

    # AwoooP 自訂 namespace（非標準，但必需）
    "awooop.project_id": "awoooi",
    "awooop.agent_id": "openclaw-sre",
    "awooop.run_id": "uuid",
    "awooop.provider_tier": "primary" | "secondary" | "fallback",  # ADR-110 三層

    # W3C TraceContext（由 OTel SDK 自動注入）
    # trace_id, span_id, parent_id
}
```

### D3 — telemetry.py 修補（P0-08 + semconv 升級）

**P0-08 修補（PR-01，立即）**：

```python
# 修補前（telemetry.py:71，不安全）
if "192.168.0.188" not in endpoint:
    raise ValueError(f"Unsupported OTEL endpoint: {endpoint}")

# 修補後（允許任何 HTTPS 或設定允許清單的 endpoint）
ALLOWED_OTEL_ENDPOINTS = {
    settings.OTEL_EXPORTER_OTLP_ENDPOINT,   # 從環境變數讀取
}
# 移除 hardcode assert，直接使用設定值
```

**semconv 升級（Phase 2）**：

```python
# apps/api/src/services/telemetry.py
from opentelemetry import trace
from opentelemetry.semconv._incubating.attributes import gen_ai_attributes

class GenAITracer:
    """符合 GenAI Semantic Conventions 1.0 的 LLM call tracer"""

    def __init__(self, tracer_name: str = "awooop.platform"):
        self._tracer = trace.get_tracer(tracer_name)

    def start_llm_span(
        self,
        system: str,         # "ollama", "openai", etc.
        operation: str,      # "chat", "embeddings"
        model: str,
        project_id: str,
        agent_id: str,
        run_id: str,
        provider_tier: str   # "primary", "secondary", "fallback"
    ) -> trace.Span:
        span_name = f"{operation} {system}"
        span = self._tracer.start_span(span_name)
        span.set_attributes({
            gen_ai_attributes.GEN_AI_SYSTEM: system,
            gen_ai_attributes.GEN_AI_OPERATION_NAME: operation,
            gen_ai_attributes.GEN_AI_REQUEST_MODEL: model,
            "awooop.project_id": project_id,
            "awooop.agent_id": agent_id,
            "awooop.run_id": run_id,
            "awooop.provider_tier": provider_tier,
        })
        return span

    def end_llm_span(
        self,
        span: trace.Span,
        response_model: str,
        prompt_tokens: int,
        completion_tokens: int,
        finish_reason: str
    ) -> None:
        span.set_attributes({
            gen_ai_attributes.GEN_AI_RESPONSE_MODEL: response_model,
            gen_ai_attributes.GEN_AI_USAGE_PROMPT_TOKENS: prompt_tokens,
            gen_ai_attributes.GEN_AI_USAGE_COMPLETION_TOKENS: completion_tokens,
            gen_ai_attributes.GEN_AI_RESPONSE_FINISH_REASONS: [finish_reason],
        })
        span.end()
```

### D4 — OTel 依賴版本更新（INV-5）

```toml
# pyproject.toml 更新
opentelemetry-sdk = ">=1.25.0"                                    # GenAI semconv 支援
opentelemetry-exporter-otlp-proto-grpc = ">=1.25.0"
opentelemetry-semantic-conventions = ">=0.46b0"                   # incubating GenAI attrs
```

### D5 — SignOz 上報設定（188:24318）

```python
# apps/api/src/services/telemetry.py
# OTEL_EXPORTER_OTLP_ENDPOINT 從環境變數讀取（不再 hardcode）
# K8s deployment 中設置：
#   OTEL_EXPORTER_OTLP_ENDPOINT=http://192.168.0.188:24318

# EwoooC 可設置不同的 endpoint（PR-01 修補後解除封鎖）
```

**K8s ConfigMap（per-tenant 可配置）**：
```yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: awooop-telemetry-config
data:
  OTEL_EXPORTER_OTLP_ENDPOINT: "http://192.168.0.188:24318"
  OTEL_SERVICE_NAME: "awooop-platform"
  OTEL_RESOURCE_ATTRIBUTES: "deployment.environment=production"
```

### D6 — GCP Ollama 三層拓撲的 span 標記（ADR-110 整合）

```python
# ADR-110 三層拓撲的 span 標記
PROVIDER_TIER_MAP = {
    "34.143.170.20": "primary",    # GCP-A
    "34.21.145.224": "secondary",  # GCP-B
    "192.168.0.111": "fallback",   # Local
}

def get_provider_tier(ollama_host: str) -> str:
    for ip, tier in PROVIDER_TIER_MAP.items():
        if ip in ollama_host:
            return tier
    return "unknown"
```

Fallback 事件（primary → secondary 或 secondary → fallback）必須在 span 上標記 `awooop.provider_tier`，讓 SignOz 可以分析 fallback 頻率。

---

## 後果

### Benefits
- GenAI semconv 標準化：SignOz / Grafana 可用通用 GenAI dashboard
- `gen_ai.usage.prompt_tokens` / `completion_tokens` 進 OTel：budget 對帳有 trace 依據
- EwoooC 解封（PR-01 移除 hardcode assert）
- GCP Ollama 三層 fallback 可視化

### Costs
- OTel 版本升級（>=1.25.0）需要更新 pyproject.toml + 驗證現有 instrumentation
- 現有 LLM call 程式碼需要加 `start_llm_span` / `end_llm_span` 包裝

### Risks
- OTel GenAI semconv incubating 狀態（not stable），未來可能有 attribute 名稱變更
- 緩解：`awooop.*` 自訂 namespace 隔離，即使 semconv 變更只需更新 GenAI attributes 部分

---

## 驗收標準

- [ ] `telemetry.py:71` hardcoded IP assert 移除（PR-01 完成）
- [ ] OTel 版本升級至 >=1.25.0（Phase 2）
- [ ] LLM call span 包含 `gen_ai.usage.prompt_tokens` / `completion_tokens`（整合測試）
- [ ] SignOz 可看到 `chat ollama` span 帶 token 計數（Phase 2 驗收）
- [ ] GCP Ollama fallback 事件在 SignOz 有 `awooop.provider_tier` 標記可查詢

## 關聯

- ADR-110（GCP Ollama 三層拓撲，fallback 事件標記）
- ADR-120（token budget，telemetry token 計數對帳）
- INV-4（telemetry.py:71 hardcoded IP，P0-08）
- INV-5（OTel 版本相容性矩陣）
