codebook / potato /trace_ingestion /webhook_receiver.py
davidjurgens's picture
Deploy: Potato — Codebook Annotation
aceb1b2 verified
Raw
History Blame Contribute Delete
5.87 kB
"""
Webhook Receiver for Trace Ingestion
Accepts agent traces via HTTP webhooks from external platforms.
Supports generic JSON format and LangSmith-specific format.
Validates webhook authentication via API key.
"""
import hashlib
import hmac
import json
import logging
import time
import uuid
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
class WebhookReceiver:
"""Receives and normalizes agent traces from webhook POST requests."""
def __init__(self, api_key: str = "", allowed_formats: Optional[List[str]] = None):
self.api_key = api_key
self.allowed_formats = allowed_formats or ["auto", "generic", "langsmith"]
def validate_auth(self, request_headers: Dict[str, str]) -> bool:
"""Validate webhook authentication."""
if not self.api_key:
return True # No auth required
# Check Authorization header
auth = request_headers.get("Authorization", "")
if auth.startswith("Bearer "):
return hmac.compare_digest(auth[7:], self.api_key)
# Check X-API-Key header
api_key = request_headers.get("X-API-Key", "")
if api_key:
return hmac.compare_digest(api_key, self.api_key)
return False
def process_webhook(
self, payload: Dict[str, Any], format_hint: str = "auto"
) -> Optional[Dict[str, Any]]:
"""
Process a webhook payload and normalize to Potato trace format.
Args:
payload: Raw webhook JSON payload
format_hint: "auto", "generic", or "langsmith"
Returns:
Normalized trace dict or None on failure
"""
if format_hint == "auto":
format_hint = self._detect_format(payload)
try:
if format_hint == "langsmith":
return self._normalize_langsmith(payload)
else:
return self._normalize_generic(payload)
except Exception as e:
logger.error(f"Failed to process webhook: {e}")
return None
def _detect_format(self, payload: Dict[str, Any]) -> str:
"""Auto-detect the payload format."""
# LangSmith uses "runs" key and has run_type field
if "runs" in payload or "run_type" in payload:
return "langsmith"
return "generic"
def _normalize_generic(self, payload: Dict[str, Any]) -> Dict[str, Any]:
"""Normalize a generic webhook payload."""
trace_id = payload.get("id", str(uuid.uuid4())[:8])
steps = payload.get("steps", [])
# Normalize each step
normalized_steps = []
for i, step in enumerate(steps):
normalized_steps.append({
"step_index": step.get("step_index", i),
"action_type": step.get("action_type", step.get("type", "unknown")),
"thought": step.get("thought", ""),
"observation": step.get("observation", step.get("output", "")),
"screenshot_url": step.get("screenshot_url", ""),
"timestamp": step.get("timestamp", i),
"coordinates": step.get("coordinates"),
"element": step.get("element"),
"viewport": step.get("viewport"),
})
return {
"id": f"webhook_{trace_id}",
"task_description": payload.get(
"task_description",
payload.get("task", payload.get("description", "")),
),
"site": payload.get("site", payload.get("url", "")),
"steps": normalized_steps,
"metadata": {
"source": "webhook",
"format": "generic",
"received_at": time.time(),
"original_id": trace_id,
},
}
def _normalize_langsmith(self, payload: Dict[str, Any]) -> Dict[str, Any]:
"""Normalize a LangSmith webhook payload."""
runs = payload.get("runs", [payload] if "run_type" in payload else [])
if not runs:
return self._normalize_generic(payload)
# Extract from the root/parent run
root_run = runs[0]
trace_id = root_run.get("id", str(uuid.uuid4())[:8])
# Convert LangSmith runs to steps
steps = []
for i, run in enumerate(runs):
run_type = run.get("run_type", "chain")
inputs = run.get("inputs", {})
outputs = run.get("outputs", {})
steps.append({
"step_index": i,
"action_type": self._langsmith_type_to_action(run_type),
"thought": inputs.get("input", inputs.get("prompt", "")),
"observation": str(outputs.get("output", outputs.get("text", ""))),
"screenshot_url": "",
"timestamp": i,
"metadata": {
"run_id": run.get("id"),
"run_type": run_type,
"latency": run.get("latency"),
"status": run.get("status"),
},
})
return {
"id": f"langsmith_{trace_id}",
"task_description": root_run.get("name", ""),
"site": "",
"steps": steps,
"metadata": {
"source": "langsmith",
"format": "langsmith",
"received_at": time.time(),
"original_id": trace_id,
"project_name": root_run.get("project_name", ""),
},
}
@staticmethod
def _langsmith_type_to_action(run_type: str) -> str:
"""Map LangSmith run types to action types."""
mapping = {
"tool": "click",
"llm": "type",
"chain": "navigate",
"retriever": "scroll",
}
return mapping.get(run_type, "wait")