-
Notifications
You must be signed in to change notification settings - Fork 3.4k
chore(llma): Add PostHog LLM Analytics instrumentation to PR approval agent #52984
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
8bc0326
e81b374
e424b62
12a7cdf
5b62ad6
26b5b5f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,202 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """PostHog LLM Analytics instrumentation for the PR approval agent. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Captures $ai_generation and $ai_trace events so stamphog runs | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| are visible in the LLM Analytics dashboard. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import os | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import time | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import uuid | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from dataclasses import dataclass, field | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from typing import Any | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| from posthoganalytics import Posthog | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| _INTERNAL_PROJECT_API_KEY = "sTMFPsFhdP1Ssg" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| _INTERNAL_HOST = "https://us.i.posthog.com" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| DISTINCT_ID = "stamphog-ci-bot" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def create_client() -> Posthog | None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """Create a PostHog client for LLM analytics. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Uses the internal PostHog project key by default so no extra secrets | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| are needed. Set OPT_OUT_CAPTURE=1 to disable. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if os.environ.get("OPT_OUT_CAPTURE", "").lower() in ("true", "yes", "1"): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return None | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| api_key = os.environ.get("STAMPHOG_POSTHOG_API_KEY", _INTERNAL_PROJECT_API_KEY) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| host = os.environ.get("STAMPHOG_POSTHOG_HOST", _INTERNAL_HOST) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return Posthog(api_key, host=host) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @dataclass | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| class TraceRecorder: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """Records LLM analytics events for a single pipeline run. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Collects $ai_generation events from reviewer calls and emits | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| a $ai_trace event when the pipeline completes. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| client: Posthog | None | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| trace_id: str = field(default_factory=lambda: str(uuid.uuid4())) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| _start_time: float = field(default_factory=time.monotonic) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| _generations: list[dict[str, Any]] = field(default_factory=list) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| _pr_metadata: dict[str, Any] = field(default_factory=dict) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @property | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def enabled(self) -> bool: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return self.client is not None | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def set_pr_metadata( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| pr_number: int, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| repo: str, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| author: str, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| title: str, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| tier: str, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| t1_subclass: str, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| lines_total: int, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| files_changed: int, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) -> None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self._pr_metadata = { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "stamphog_pr_number": pr_number, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "stamphog_repo": repo, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "stamphog_author": author, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "stamphog_title": title[:200], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "stamphog_tier": tier, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "stamphog_t1_subclass": t1_subclass, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "stamphog_lines_total": lines_total, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "stamphog_files_changed": files_changed, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def record_generation( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| *, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| model: str, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| input_messages: list[dict[str, str]], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| output_text: str, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| usage: dict[str, Any] | None = None, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| model_usage: dict[str, Any] | None = None, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| duration_ms: int = 0, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| total_cost_usd: float | None = None, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| num_turns: int = 0, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| stop_reason: str | None = None, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) -> None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+73
to
+85
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The
Suggested change
The call site in Prompt To Fix With AIThis is a comment left during a code review.
Path: tools/pr-approval-agent/analytics.py
Line: 73-86
Comment:
**`structured_output` parameter is accepted but never used**
The `structured_output: Any = None` parameter is declared in `record_generation`'s signature but is never referenced anywhere in the method body. This is a superfluous part — remove it to keep the interface minimal and avoid confusion about whether the parameter has any effect.
```suggestion
def record_generation(
self,
*,
model: str,
input_messages: list[dict[str, str]],
output_text: str,
usage: dict[str, Any] | None = None,
model_usage: dict[str, Any] | None = None,
duration_ms: int = 0,
total_cost_usd: float | None = None,
num_turns: int = 0,
stop_reason: str | None = None,
) -> None:
```
The call site in `reviewer.py` would need to drop the `structured_output=structured_output` keyword argument accordingly.
How can I resolve this? If you propose a fix, please make it concise. |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """Record a single LLM generation (reviewer call).""" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if not self.enabled: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| input_tokens = 0 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| output_tokens = 0 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| cache_read_tokens = 0 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| cache_creation_tokens = 0 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if usage: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| input_tokens = usage.get("input_tokens", 0) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| output_tokens = usage.get("output_tokens", 0) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| cache_read_tokens = usage.get("cache_read_input_tokens", 0) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| cache_creation_tokens = usage.get("cache_creation_input_tokens", 0) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| generation = { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "input_tokens": input_tokens, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "output_tokens": output_tokens, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "duration_ms": duration_ms, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "total_cost_usd": total_cost_usd, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self._generations.append(generation) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| properties: dict[str, Any] = { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "$ai_trace_id": self.trace_id, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "$ai_model": model, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "$ai_provider": "anthropic", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "$ai_input": input_messages, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "$ai_input_tokens": input_tokens, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "$ai_output_choices": [{"role": "assistant", "content": output_text[:5000]}], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "$ai_output_tokens": output_tokens, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "$ai_latency": duration_ms / 1000.0, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "$ai_is_error": False, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "$ai_stream": True, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| **self._pr_metadata, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "stamphog_num_turns": num_turns, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "stamphog_stop_reason": stop_reason or "", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if total_cost_usd is not None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| properties["$ai_total_cost_usd"] = total_cost_usd | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if cache_read_tokens: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| properties["$ai_cache_read_input_tokens"] = cache_read_tokens | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if cache_creation_tokens: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| properties["$ai_cache_creation_input_tokens"] = cache_creation_tokens | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if model_usage: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for model_name, model_stats in model_usage.items(): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| properties[f"stamphog_model_{model_name}_input_tokens"] = model_stats.get("input_tokens", 0) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| properties[f"stamphog_model_{model_name}_output_tokens"] = model_stats.get("output_tokens", 0) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self.client.capture( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| event="$ai_generation", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| distinct_id=DISTINCT_ID, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| properties=properties, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| except Exception: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| pass | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def record_trace( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| *, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| verdict: str, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| gate_verdict: str, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| gate_results: list[dict[str, Any]], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| reviewer_output: dict[str, Any] | None = None, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) -> None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """Record the overall pipeline trace.""" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if not self.enabled: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| total_latency = time.monotonic() - self._start_time | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| total_input_tokens = sum(g["input_tokens"] for g in self._generations) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| total_output_tokens = sum(g["output_tokens"] for g in self._generations) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| total_cost = sum(g["total_cost_usd"] for g in self._generations if g["total_cost_usd"] is not None) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| properties: dict[str, Any] = { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "$ai_trace_id": self.trace_id, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "$ai_latency": total_latency, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "$ai_input_tokens": total_input_tokens, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "$ai_output_tokens": total_output_tokens, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "$ai_input_state": { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "gate_verdict": gate_verdict, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "gates": gate_results, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| **self._pr_metadata, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "$ai_output_state": { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "final_verdict": verdict, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "reviewer": reviewer_output, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| **self._pr_metadata, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "stamphog_final_verdict": verdict, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "stamphog_gate_verdict": gate_verdict, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "stamphog_generation_count": len(self._generations), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if total_cost > 0: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| properties["$ai_total_cost_usd"] = total_cost | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self.client.capture( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| event="$ai_trace", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| distinct_id=DISTINCT_ID, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| properties=properties, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| except Exception: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| pass | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def flush(self) -> None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| """Ensure all events are sent before the process exits.""" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if self.enabled: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| self.client.flush() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| except Exception: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| pass | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The internal project API key is hardcoded as a string literal in a public open-source repository, and is also documented verbatim in
README.md. Even if this is a write-only ingest key, committing credentials in source is against best practices — especially in a public repo where any reader can see it.A
STAMPHOG_POSTHOG_API_KEYenv-var override already exists, so the hardcoded fallback could be removed entirely. The caller would need to supply the key via the environment variable, keeping credentials out of source.Prompt To Fix With AI