mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 00:08:55 +00:00
601 lines
22 KiB
Python
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()
|
|
|