Files

601 lines
22 KiB
Python

#!/usr/bin/env python3
# Copyright 2026 OpenObserve Inc.
# Source of truth: openobserve scripts/ci-telemetry/o2_llm_trace.py; o2-enterprise .github/scripts/o2_llm_trace.py is a byte-identical copy.
"""gen_ai traces for CI LLM calls, shaped like scripts/ai-review/run-review.mjs, exported to OpenObserve as OTLP/JSON."""
import atexit
import base64
import hashlib
import json
import os
import re
import threading
import time
import urllib.parse
import urllib.request
DEFAULT_STREAM = "github_actions_ai_traces"
DEFAULT_TIMEOUT_MS = 10_000
DEFAULT_BATCH_SIZE = 1
EXPORT_INTERVAL_S = 1.0
SCOPE_NAME = "scripts/ci-telemetry/o2_llm_trace.py"
SERVICE_VERSION = "1.0.0"
MAX_SYSTEM_CHARS = 100_000
MAX_USER_CHARS = 150_000
MAX_ERROR_CHARS = 500
SPAN_KIND_INTERNAL = 1
SPAN_KIND_CLIENT = 3
STATUS_OK = 1
STATUS_ERROR = 2
# Same redaction rules as scripts/ai-review/src/scrub.mjs.
SECRET_ENV_NAME_RE = re.compile(r"(KEY|TOKEN|SECRET|PASSWORD|AUTH)")
MIN_SECRET_LENGTH = 8
REDACTED = "[redacted]"
SECRET_PATTERNS = (
re.compile(r"gh[pousr]_[A-Za-z0-9]{20,}"),
re.compile(r"github_pat_[A-Za-z0-9_]{20,}"),
re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)"),
re.compile(r"sk-[A-Za-z0-9_-]{20,}"),
)
AUTH_HEADER_RE = re.compile(r"(Authorization:)[^\n]*", re.IGNORECASE)
GITHUB_RESOURCE_ENV = (
("github.repository", "GITHUB_REPOSITORY"),
("github.workflow", "GITHUB_WORKFLOW"),
("github.run_id", "GITHUB_RUN_ID"),
("github.run_number", "GITHUB_RUN_NUMBER"),
("github.run_attempt", "GITHUB_RUN_ATTEMPT"),
("github.job", "GITHUB_JOB"),
("github.actor", "GITHUB_ACTOR"),
("github.event_name", "GITHUB_EVENT_NAME"),
("github.ref", "GITHUB_REF"),
("github.sha", "GITHUB_SHA"),
)
class Span:
"""A started span; the Tracer serializes it when it ends."""
def __init__(self, trace_id, parent_id, name, kind, attributes):
self.trace_id = trace_id
self.span_id = _random_hex(8)
self.parent_id = parent_id
self.name = name
self.kind = kind
self.start_ns = time.time_ns()
self.attributes = dict(attributes or {})
self.events = []
self.status = {"code": STATUS_OK}
self.ended = False
def to_otlp(self, end_ns):
span = {
"traceId": self.trace_id,
"spanId": self.span_id,
"name": self.name,
"kind": self.kind,
"startTimeUnixNano": str(self.start_ns),
"endTimeUnixNano": str(end_ns),
"attributes": otlp_attributes(self.attributes),
"status": self.status,
}
if self.parent_id:
span["parentSpanId"] = self.parent_id
if self.events:
span["events"] = self.events
return span
class Tracer:
"""One root span per script run; ended spans are exported in the background about every second."""
def __init__(self, service_name, root_name, provider="deepseek", content_prefix="ai.llm", root_attributes=None, env=None):
self.env = os.environ if env is None else env
self.service_name = service_name
self.provider = provider
self.content_prefix = content_prefix
self.endpoint = trace_endpoint(self.env)
self.auth = auth_header(self.env)
self.enabled = bool(self.endpoint and self.auth)
self.trace_id = trace_id_from_env(self.env)
self.timeout_s = export_timeout_s(self.env)
self.batch_size = export_batch_size(self.env)
self.secrets = secret_values(self.env)
self.exported = 0
self.failed = 0
self._queue = []
self._lock = threading.Lock()
self._stop = threading.Event()
self._thread = None
self._shut_down = False
self.root = None
self.root_error = None
if not self.enabled:
print(
"::notice::LLM trace export disabled: O2_TRACE_INGEST_URL and "
"O2_TRACE_AUTH (or O2_TRACE_USERNAME/O2_TRACE_PASSWORD) are not set."
)
return
attrs = {"workflow.trace_id": self.trace_id, "github.pr.number": pr_number(self.env)}
attrs.update(root_attributes or {})
self.root = self.start_span(root_name, attrs, parent=None)
atexit.register(self.shutdown)
def start_span(self, name, attributes=None, parent=None, kind=SPAN_KIND_INTERNAL):
"""Start a span under `parent` (default: the root span); None when export is disabled."""
if not self.enabled:
return None
parent = self.root if parent is None else parent
return Span(self.trace_id, parent.span_id if parent else None, name, kind, _clean(attributes))
def set_attributes(self, span, attributes):
if span is not None:
span.attributes.update(_clean(attributes))
def set_root_attributes(self, attributes):
self.set_attributes(self.root, attributes)
def set_outcome(self, outcome, error=None, attributes=None):
"""Record the run's workflow.outcome; an error marks the root span ERROR when it ends."""
self.set_root_attributes(dict(attributes or {}, **{"workflow.outcome": outcome}))
self.root_error = error
def end_span(self, span, attributes=None, error=None):
if span is None or span.ended:
return
span.ended = True
self.set_attributes(span, attributes)
if error is not None:
message = str(error)[:MAX_ERROR_CHARS]
span.attributes["error.type"] = type(error).__name__
span.attributes["error.message"] = message
span.status = {"code": STATUS_ERROR, "message": message}
span.events.append(
{
"timeUnixNano": str(time.time_ns()),
"name": "exception",
"attributes": otlp_attributes({"exception.type": type(error).__name__, "exception.message": message}),
}
)
with self._lock:
self._queue.append(span.to_otlp(time.time_ns()))
if self._thread is None and not self._shut_down:
self._thread = threading.Thread(target=self._export_loop, name="o2_llm_trace", daemon=True)
self._thread.start()
def llm_call(self, model, messages, parent=None, agent_name=None, agent_key=None, reasoning_effort=None, timeout_ms=None, attributes=None):
"""A `gen_ai.chat` span for one logical call; retries go through the same LlmCall."""
attrs = {
"gen_ai.operation.name": "chat",
"gen_ai.provider.name": self.provider,
"gen_ai.request.model": model,
"gen_ai.request.reasoning_effort": reasoning_effort or "default",
f"{self.content_prefix}.operation.name": "chat",
"gen_ai.agent.name": agent_name,
"ai.agent.key": agent_key,
"ai.agent.name": agent_name,
"timeout.ms": timeout_ms,
}
span = self.start_span("gen_ai.chat", attrs, parent=parent, kind=SPAN_KIND_CLIENT)
if span is not None:
span.attributes.update(request_attributes(messages, self.content_prefix, self.secrets))
self.set_attributes(span, attributes)
return LlmCall(self, span, model)
def chat(self, client, parent=None, agent_name=None, agent_key=None, timeout_ms=None, attributes=None, **kwargs):
"""`client.chat.completions.create(**kwargs)` traced as one gen_ai.chat span; returns or re-raises."""
with self.llm_call(
kwargs.get("model"),
kwargs.get("messages") or [],
parent=parent,
agent_name=agent_name,
agent_key=agent_key,
reasoning_effort=kwargs.get("reasoning_effort"),
timeout_ms=timeout_ms,
attributes=attributes,
) as call:
return call.create(client, **kwargs)
def shutdown(self):
"""End the root span and export what is queued, bounded by O2_TRACE_EXPORT_TIMEOUT_MS."""
if not self.enabled or self._shut_down:
return
self.end_span(self.root, error=self.root_error)
with self._lock:
self._shut_down = True
thread = self._thread
deadline = time.monotonic() + self.timeout_s
self._stop.set()
if thread is not None:
thread.join(max(0.0, deadline - time.monotonic()))
self._drain(deadline)
with self._lock:
dropped = len(self._queue)
if dropped:
print(f"::warning::LLM trace export: {dropped} span(s) not sent before the export deadline.")
print(f"LLM trace export: {self.exported} span(s) sent, {self.failed} failed, trace_id={self.trace_id}")
def _export_loop(self):
while not self._stop.wait(EXPORT_INTERVAL_S):
self._drain(None)
def _drain(self, deadline):
while True:
if deadline is None and self._stop.is_set():
return
if deadline is not None and time.monotonic() >= deadline:
return
with self._lock:
batch = self._queue[: self.batch_size]
del self._queue[: self.batch_size]
if not batch:
return
timeout = self.timeout_s if deadline is None else max(0.1, deadline - time.monotonic())
sent = self._post(batch, timeout)
with self._lock:
if sent:
self.exported += len(batch)
else:
self.failed += len(batch)
def _post(self, spans, timeout):
payload = build_payload(otlp_attributes(resource_attributes(self.service_name, self.env, self.trace_id)), spans)
request = urllib.request.Request(
self.endpoint,
data=json.dumps(payload).encode("utf-8"),
method="POST",
headers={"Content-Type": "application/json", "Authorization": self.auth, "stream-name": stream_name(self.env)},
)
try:
with urllib.request.urlopen(request, timeout=timeout) as resp:
resp.read()
return True
except Exception as exc:
if self.failed < 3:
print(f"::warning::LLM trace export failed: {type(exc).__name__}: {scrub(str(exc), self.secrets)[:300]}")
return False
class LlmCall:
"""Accumulates attempts and usage for one gen_ai.chat span; use it as a context manager."""
def __init__(self, tracer, span, model):
self.tracer = tracer
self.span = span
self.model = model
self.attempts = 0
self.last_error = None
self.usage = {}
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
self.end(exc)
return False
def create(self, client, **kwargs):
self.attempts += 1
self.tracer.set_attributes(self.span, request_params(kwargs))
try:
response = client.chat.completions.create(**kwargs)
except Exception as exc:
self.last_error = exc
raise
self.record_response(response, kwargs.get("model") or self.model)
return response
def record_response(self, response, model=None):
self.last_error = None
if self.span is None:
return
data = response_dict(response) or {}
usage = usage_from_response(data)
# Every attempt is billed, so token counts sum over retries, unlike callOpencode.
for key, value in usage.items():
if value is not None:
self.usage[key] = self.usage.get(key, 0) + value
self.span.attributes.update(
response_attributes(data, self.tracer.content_prefix, self.tracer.secrets, model or self.model)
)
def reject(self, error):
"""Mark the latest attempt failed for a caller-side reason, such as an unusable response."""
self.last_error = error
def end(self, error=None):
prefix = self.tracer.content_prefix
attrs = {f"{prefix}.attempts": self.attempts}
# No gen_ai.usage.cost: OpenObserve prices spans from these token counts when the client sends none.
attrs.update(usage_attributes(self.usage, prefix))
self.tracer.end_span(self.span, attrs, error if error is not None else self.last_error)
def trace_endpoint(env):
raw = (env.get("O2_TRACE_INGEST_URL") or "").strip().rstrip("/")
if not raw:
return ""
if re.search(r"/api/[^/]+/v1/traces$", raw):
return raw
if re.search(r"/api/[^/]+$", raw):
return f"{raw}/v1/traces"
org = urllib.parse.quote(env.get("O2_TRACE_ORG") or "default", safe="")
return f"{raw}/api/{org}/v1/traces"
def auth_header(env):
auth = (env.get("O2_TRACE_AUTH") or "").strip()
if auth:
return auth if auth.startswith("Basic ") else f"Basic {auth}"
user = env.get("O2_TRACE_USERNAME")
password = env.get("O2_TRACE_PASSWORD")
if not user or not password:
return ""
return "Basic " + base64.b64encode(f"{user}:{password}".encode("utf-8")).decode("ascii")
def stream_name(env):
return env.get("O2_TRACE_STREAM") or DEFAULT_STREAM
def export_timeout_s(env):
try:
value = float(env.get("O2_TRACE_EXPORT_TIMEOUT_MS") or DEFAULT_TIMEOUT_MS)
except ValueError:
value = DEFAULT_TIMEOUT_MS
return (value if value > 0 else DEFAULT_TIMEOUT_MS) / 1000.0
def export_batch_size(env):
value = (env.get("O2_TRACE_EXPORT_BATCH_SIZE") or "").strip()
return int(value) if value.isdigit() and int(value) > 0 else DEFAULT_BATCH_SIZE
def trace_id_from_env(env):
value = (env.get("O2_TRACE_ID") or "").strip().lower()
if re.fullmatch(r"[0-9a-f]{32}", value) and value.strip("0"):
return value
return _random_hex(16)
def pr_number(env):
explicit = (env.get("PR_NUMBER") or "").strip()
if explicit.isdigit():
return int(explicit)
match = re.match(r"refs/pull/(\d+)/", env.get("GITHUB_REF") or "")
if match:
return int(match.group(1))
path = env.get("GITHUB_EVENT_PATH")
if not path:
return None
try:
with open(path, encoding="utf-8") as fh:
event = json.load(fh)
except (OSError, ValueError):
return None
if not isinstance(event, dict):
return None
number = (event.get("pull_request") or {}).get("number")
if number is None and "pull_request" in (event.get("issue") or {}):
number = event["issue"].get("number")
return number if isinstance(number, int) else None
def workflow_run_url(env):
repo = env.get("GITHUB_REPOSITORY")
run_id = env.get("GITHUB_RUN_ID")
if not repo or not run_id:
return None
return f"{env.get('GITHUB_SERVER_URL') or 'https://github.com'}/{repo}/actions/runs/{run_id}"
def resource_attributes(service_name, env, trace_id=None):
"""The traceResourceAttributes() set of run-review.mjs plus workflow.trace_id."""
attrs = {
"service.name": service_name,
"service.version": SERVICE_VERSION,
"telemetry.sdk.language": "python",
"telemetry.sdk.name": "o2_llm_trace",
"ci.provider.name": "github-actions",
}
for key, var in GITHUB_RESOURCE_ENV:
attrs[key] = env.get(var)
attrs["github.workflow.run_url"] = workflow_run_url(env)
attrs["workflow.trace_id"] = trace_id
return attrs
def secret_values(env):
values = {
value.strip()
for name, value in env.items()
if SECRET_ENV_NAME_RE.search(name) and isinstance(value, str) and len(value.strip()) >= MIN_SECRET_LENGTH
}
return sorted(values, key=len, reverse=True)
def scrub(text, secrets):
for value in secrets:
text = text.replace(value, REDACTED)
for pattern in SECRET_PATTERNS:
text = pattern.sub(REDACTED, text)
return AUTH_HEADER_RE.sub(lambda m: f"{m.group(1)} {REDACTED}", text)
def sha256(text):
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def message_parts(content, limit, secrets):
"""gen_ai message parts; only text is kept, so inline images never reach the trace."""
if isinstance(content, str):
return [{"type": "text", "content": scrub(content[:limit], secrets)}]
parts = []
for part in content or []:
if isinstance(part, dict) and part.get("type") == "text":
parts.append({"type": "text", "content": scrub(str(part.get("text") or "")[:limit], secrets)})
elif isinstance(part, dict):
parts.append({"type": str(part.get("type") or "unknown")})
return parts
def _parts_text(parts):
return "\n\n".join(p["content"] for p in parts if "content" in p)
def request_attributes(messages, prefix, secrets):
"""Request content in the genAiRequestAttributes() shape of scripts/ai-review/telemetry.mjs."""
system_parts = []
inputs = []
for message in messages:
if not isinstance(message, dict):
continue
role = message.get("role") or "user"
if role in ("system", "developer"):
system_parts.extend(message_parts(message.get("content"), MAX_SYSTEM_CHARS, secrets))
else:
inputs.append({"role": role, "parts": message_parts(message.get("content"), MAX_USER_CHARS, secrets)})
system_text = _parts_text(system_parts)
user_text = "\n\n".join(_parts_text(m["parts"]) for m in inputs)
return {
"gen_ai.system_instructions": json.dumps(system_parts, ensure_ascii=False),
"gen_ai.input.messages": json.dumps(inputs, ensure_ascii=False),
f"{prefix}.system_instructions.chars": len(system_text),
f"{prefix}.system_instructions.sha256": sha256(system_text),
f"{prefix}.input.chars": len(user_text),
f"{prefix}.input.sha256": sha256(user_text),
}
def request_params(kwargs):
return _clean(
{
"gen_ai.request.model": kwargs.get("model"),
"gen_ai.request.max_tokens": kwargs.get("max_tokens") or kwargs.get("max_completion_tokens"),
"gen_ai.request.temperature": kwargs.get("temperature"),
"gen_ai.request.reasoning_effort": kwargs.get("reasoning_effort"),
}
)
def response_dict(response):
if response is None:
return None
if isinstance(response, dict):
return response
for method in ("model_dump", "to_dict", "dict"):
fn = getattr(response, method, None)
if callable(fn):
try:
value = fn()
except Exception:
continue
if isinstance(value, dict):
return value
return None
def response_text(data):
choices = data.get("choices") or []
message = choices[0].get("message") if choices and isinstance(choices[0], dict) else None
content = (message or {}).get("content")
return content if isinstance(content, str) else ""
def response_attributes(data, prefix, secrets, requested_model):
"""Response content in the genAiResponseAttributes() shape; usage is added when the call ends."""
text = scrub(response_text(data), secrets)
return _clean(
{
"gen_ai.output.messages": json.dumps(
[{"role": "assistant", "parts": [{"type": "text", "content": text}]}], ensure_ascii=False
),
"gen_ai.response.id": data.get("id"),
"gen_ai.response.model": data.get("model") or requested_model,
"gen_ai.response.finish_reasons": finish_reasons(data),
f"{prefix}.output.chars": len(text),
f"{prefix}.output.sha256": sha256(text),
}
)
def _count(value):
if isinstance(value, bool) or not isinstance(value, (int, float)) or value < 0:
return None
return int(value)
def usage_from_response(data):
"""Token counts from an OpenAI-compatible (DeepSeek) chat completion dict; absent fields stay None."""
usage = data.get("usage") or {}
prompt_details = usage.get("prompt_tokens_details") or {}
completion_details = usage.get("completion_tokens_details") or {}
cache_hit = _count(usage.get("prompt_cache_hit_tokens"))
if cache_hit is None:
cache_hit = _count(prompt_details.get("cached_tokens"))
return {
"input": _count(usage.get("prompt_tokens")),
"cache_read": cache_hit,
"output": _count(usage.get("completion_tokens")),
"reasoning": _count(completion_details.get("reasoning_tokens")),
"total": _count(usage.get("total_tokens")),
}
def usage_attributes(usage, prefix="ai.llm"):
"""OpenObserve's extractor treats input_tokens as including gen_ai.usage.cache_read.input_tokens."""
total = usage.get("total")
if total is None and usage.get("input") is not None and usage.get("output") is not None:
total = usage["input"] + usage["output"]
return {
"gen_ai.usage.input_tokens": usage.get("input"),
"gen_ai.usage.output_tokens": usage.get("output"),
"gen_ai.usage.total_tokens": total,
"gen_ai.usage.cache_read.input_tokens": usage.get("cache_read"),
"gen_ai.usage.cache_creation.input_tokens": usage.get("cache_creation"),
f"{prefix}.usage.reasoning_tokens": usage.get("reasoning"),
}
def finish_reasons(data):
reasons = [c.get("finish_reason") for c in data.get("choices") or [] if isinstance(c, dict)]
return [r for r in reasons if isinstance(r, str)] or None
def otlp_value(value):
if isinstance(value, bool):
return {"boolValue": value}
if isinstance(value, int):
return {"intValue": str(value)}
if isinstance(value, float):
return {"doubleValue": value}
if isinstance(value, (list, tuple)):
return {"arrayValue": {"values": [otlp_value(v) for v in value]}}
return {"stringValue": str(value)}
def otlp_attributes(attrs):
return [{"key": key, "value": otlp_value(value)} for key, value in attrs.items() if value is not None and value != ""]
def build_payload(resource_attrs, spans):
return {
"resourceSpans": [
{
"resource": {"attributes": resource_attrs},
"scopeSpans": [{"scope": {"name": SCOPE_NAME, "version": SERVICE_VERSION}, "spans": spans}],
}
]
}
def _clean(attributes):
return {k: v for k, v in (attributes or {}).items() if v is not None and v != ""}
def _random_hex(n_bytes):
return os.urandom(n_bytes).hex()