Skip to content

Commit 384d9b5

Browse files
feat(posthog): add per-metric summary reports (#3824)
Add PostHog analytics per-metric summary reports, mirroring the existing Sentry morning-digest (Viktor-style) pattern. - posthog-summary skill: pull metrics via PostHog MCP tools, format a per-metric report with current/previous deltas and trends - report_runner: headless posthog-summary turn returning the report text - report_prerequisites: PostHog + delivery-provider readiness checks - CLI: opensre posthog report run + report schedule add/list/remove/run (on-demand and scheduled) - scheduler: POSTHOG_METRIC_REPORT TaskKind, message builder, runner route - assistant prompt: PostHog report output-shape rule for consistent format - tests: builder + runner coverage
1 parent 7f935af commit 384d9b5

13 files changed

Lines changed: 762 additions & 1 deletion

File tree

core/agent_harness/prompts/assistant_agent_prompt.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,16 @@
6666
"the user asks."
6767
)
6868

69+
_POSTHOG_SUMMARY_RULE = (
70+
"PostHog metric report: open **I found:** with a one-line scope summary "
71+
"(window, project, metric count). Present a per-metric table with columns "
72+
"Metric | Current | Previous | Change | % | Trend. Use up/down/flat words for "
73+
"the trend, never colour alone. Call out the 1-3 most notable movers with a "
74+
"short why-it-matters line. When a metric window returns no data, say so "
75+
"explicitly for that metric — do not silently widen the window or present a "
76+
"failed query as zero."
77+
)
78+
6979
_HANDOFF_GUIDANCE: dict[str, str] = {
7080
"provider:local_llama_connect": (
7181
"The action planner handed off a vague local-model connection request. "
@@ -251,6 +261,7 @@ def _build_system_prompt(
251261
f"{setup_rule}\n\n"
252262
f"{_SOURCE_SCOPED_INVESTIGATION_RULE}\n\n"
253263
f"{_SENTRY_SUMMARY_RULE}\n\n"
264+
f"{_POSTHOG_SUMMARY_RULE}\n\n"
254265
f"{response_shape_rule}\n\n"
255266
f"{layout_block}"
256267
f"{terminology_rule}\n{_MARKDOWN_RULE}\n\n"
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""Shared prerequisites for PostHog metric report automation."""
2+
3+
from __future__ import annotations
4+
5+
from rich.console import Console
6+
7+
from integrations.sentry.digest_delivery import (
8+
delivery_provider_ready,
9+
digest_delivery_setup_hint,
10+
)
11+
from platform.harness_ports import configured_integration_services
12+
from platform.scheduler.types import Provider
13+
14+
_console = Console()
15+
16+
POSTHOG_SERVICE_NAMES: tuple[str, ...] = ("posthog_mcp",)
17+
18+
_NOT_CONFIGURED_HINT = (
19+
"PostHog MCP is not configured. Run `opensre integrations setup` and verify "
20+
"with `opensre integrations verify posthog_mcp` before requesting a report."
21+
)
22+
23+
24+
def posthog_report_available() -> bool:
25+
"""Return True when a PostHog data source that can serve the skill is configured."""
26+
configured = configured_integration_services()
27+
return any(name in configured for name in POSTHOG_SERVICE_NAMES)
28+
29+
30+
def posthog_not_configured_hint() -> str:
31+
"""Human-readable guidance shown when no PostHog data source is configured."""
32+
return _NOT_CONFIGURED_HINT
33+
34+
35+
_PERIOD_UNIT_HOURS: dict[str, int] = {"h": 1, "d": 24, "w": 24 * 7}
36+
_DEFAULT_WINDOW_HOURS = 24 * 7
37+
38+
39+
def stats_period_to_hours(stats_period: str, *, default: int = _DEFAULT_WINDOW_HOURS) -> int:
40+
"""Convert a relative period string (e.g. ``24h``, ``7d``, ``4w``) to hours.
41+
42+
Returns ``default`` when the value is empty or not a recognised
43+
``<int><unit>`` shape, so callers never store a misleading window on a task.
44+
"""
45+
value = (stats_period or "").strip().lower()
46+
if len(value) < 2:
47+
return default
48+
unit = value[-1]
49+
multiplier = _PERIOD_UNIT_HOURS.get(unit)
50+
if multiplier is None:
51+
return default
52+
try:
53+
magnitude = int(value[:-1])
54+
except ValueError:
55+
return default
56+
if magnitude <= 0:
57+
return default
58+
return magnitude * multiplier
59+
60+
61+
def require_posthog_integration() -> None:
62+
"""Exit when PostHog is not configured."""
63+
if posthog_report_available():
64+
return
65+
_console.print(f"[red]{_NOT_CONFIGURED_HINT}[/red]")
66+
raise SystemExit(1)
67+
68+
69+
def require_report_delivery_provider(provider: str) -> None:
70+
"""Exit when the chosen delivery provider is not configured."""
71+
provider_enum = Provider(provider)
72+
if delivery_provider_ready(provider_enum):
73+
return
74+
_console.print(f"[red]{digest_delivery_setup_hint(provider_enum)}[/red]")
75+
raise SystemExit(1)
76+
77+
78+
__all__ = [
79+
"POSTHOG_SERVICE_NAMES",
80+
"posthog_not_configured_hint",
81+
"posthog_report_available",
82+
"require_posthog_integration",
83+
"require_report_delivery_provider",
84+
"stats_period_to_hours",
85+
]
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
"""Headless PostHog per-metric report via the posthog-summary skill.
2+
3+
Mirrors :mod:`integrations.sentry.morning_digest_runner`: run one headless
4+
agent turn driven by the ``posthog-summary`` skill and return the assistant
5+
report text. Used by the ``opensre posthog report`` command and the scheduled
6+
delivery path (issue #3824).
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import logging
12+
from io import StringIO
13+
14+
from rich.console import Console
15+
16+
from core.agent_harness.accounting.run_record import DefaultRunRecordFactory
17+
from core.agent_harness.accounting.turn_accounting import DefaultTurnAccounting
18+
from core.agent_harness.error_reporting import DefaultErrorReporter
19+
from core.agent_harness.harness import AgentHarness, HarnessConfig
20+
from core.agent_harness.prompts.prompt_context import DefaultPromptContextProvider
21+
from core.agent_harness.tools.tool_provider import DefaultToolProvider
22+
from core.agent_harness.turns.default_reasoning_client import DefaultReasoningClientProvider
23+
from core.agent_harness.turns.headless_adapters import BufferOutputSink
24+
from core.agent_harness.turns.headless_dispatch import HeadlessAgent
25+
from core.agent_harness.turns.turn_results import ShellTurnResult
26+
from integrations.posthog.report_prerequisites import (
27+
posthog_not_configured_hint,
28+
posthog_report_available,
29+
)
30+
from platform.scheduler.agent_runner import AgentPayload
31+
32+
logger = logging.getLogger(__name__)
33+
34+
_REPORT_BASE_PROMPT = (
35+
"PostHog analytics report: produce a per-metric summary report of PostHog "
36+
"product analytics. Follow the posthog-summary skill workflow."
37+
)
38+
39+
_DEFAULT_STATS_PERIOD = "7d"
40+
41+
42+
def _payload_stats_period(payload: AgentPayload) -> str:
43+
period = str(payload.get("stats_period") or "").strip()
44+
return period or _DEFAULT_STATS_PERIOD
45+
46+
47+
def _payload_metrics(payload: AgentPayload) -> str:
48+
return str(payload.get("metrics") or "").strip()
49+
50+
51+
def build_report_prompt(payload: AgentPayload) -> str:
52+
"""Build the fixed headless prompt for scheduled/on-demand metric reports."""
53+
prompt = f"{_REPORT_BASE_PROMPT} Use a {_payload_stats_period(payload)} window."
54+
metrics = _payload_metrics(payload)
55+
if metrics:
56+
prompt = f"{prompt} Focus on these metrics: {metrics}."
57+
return prompt
58+
59+
60+
def _require_posthog_configured() -> None:
61+
if posthog_report_available():
62+
return
63+
raise RuntimeError(posthog_not_configured_hint())
64+
65+
66+
def _dispatch_headless_turn(message: str) -> ShellTurnResult:
67+
_require_posthog_configured()
68+
69+
harness = AgentHarness(
70+
HarnessConfig(
71+
load_env=True,
72+
hydrate_integrations=True,
73+
warm_integrations=True,
74+
persistent_tasks=False,
75+
open_storage=False,
76+
)
77+
)
78+
startup = harness.startup()
79+
session = startup.session
80+
output = BufferOutputSink()
81+
error_reporter = DefaultErrorReporter(logger)
82+
console = Console(force_terminal=False, file=StringIO())
83+
84+
agent = HeadlessAgent(
85+
session=session,
86+
output=output,
87+
tools=DefaultToolProvider(session, console, tool_action_logger=logger),
88+
prompts=DefaultPromptContextProvider(session),
89+
reasoning=DefaultReasoningClientProvider(
90+
output=output,
91+
error_reporter=error_reporter,
92+
session=session,
93+
),
94+
run_factory=DefaultRunRecordFactory(session),
95+
accounting=DefaultTurnAccounting(session, message),
96+
error_reporter=error_reporter,
97+
gather_enabled=True,
98+
is_tty=False,
99+
)
100+
return agent.dispatch(message)
101+
102+
103+
def run_posthog_report(payload: AgentPayload) -> str:
104+
"""Run one headless posthog-summary turn and return the assistant report."""
105+
message = build_report_prompt(payload)
106+
result = _dispatch_headless_turn(message)
107+
report = (result.assistant_response_text or result.action_result.response_text).strip()
108+
if not result.answered or not report:
109+
raise RuntimeError(
110+
"PostHog report failed: the reasoning client did not produce a response."
111+
)
112+
return report
113+
114+
115+
__all__ = ["build_report_prompt", "run_posthog_report"]
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
---
2+
name: posthog-summary
3+
description: Summarise PostHog product analytics into a per-metric report (like a Viktor report). Use for PostHog usage overviews, per-metric summaries, product-analytics digests, or "what happened this week" reporting.
4+
tools:
5+
- list_posthog_tools
6+
- call_posthog_tool
7+
---
8+
9+
# PostHog Summary
10+
11+
PostHog product-analytics reporting. Produce a per-metric summary report over a
12+
window. Single source (PostHog); finish here, then optionally suggest a
13+
multi-source follow-up.
14+
15+
## 1. Discover
16+
17+
`list_posthog_tools` once with `name_filter: "insights dashboard sql events"` to
18+
find the query surface. Then call `read-data-schema` (or the equivalent schema
19+
tool) BEFORE any aggregation query and only reference events and properties it
20+
confirms exist. Do not reference a property (e.g. `properties.$mcp_error`)
21+
without seeing it in the schema first — HogQL rejects queries against unknown
22+
properties. To pull metric data, use `call_posthog_tool` with
23+
`tool_name: "execute-sql"`; use insight/dashboard tools when the user names a
24+
specific dashboard.
25+
26+
## 2. Fetch metrics
27+
28+
Map user words to windows:
29+
30+
- **24h** — "today", "overnight", morning report
31+
- **7d** — "this week", default weekly report
32+
- **30d** — "this month", monthly rollup
33+
34+
Run **one small HogQL query per metric** — do NOT combine multiple metrics into a
35+
single aggregated statement, and never cross-join events against synthetic
36+
"window" rows. That combined shape is the most common cause of PostHog's
37+
`unknown error running this query`. Compute the two windows with an explicit
38+
conditional aggregate over a single time filter, e.g.:
39+
40+
```sql
41+
SELECT
42+
countIf(timestamp >= now() - INTERVAL 7 DAY) AS current,
43+
countIf(timestamp >= now() - INTERVAL 14 DAY
44+
AND timestamp < now() - INTERVAL 7 DAY) AS previous
45+
FROM events
46+
WHERE event = '$pageview'
47+
```
48+
49+
Swap `event = '...'` (or `count(DISTINCT person_id)` for active users) per
50+
metric. If a query still fails, retry that single metric once with a simpler
51+
`SELECT count() ... WHERE timestamp >= now() - INTERVAL 7 DAY`, then report the
52+
metric as failed rather than aborting the whole report.
53+
54+
Default metric set when the user does not name specific metrics (restrict to
55+
events confirmed present in the schema):
56+
57+
- Active users (`count(DISTINCT person_id)`)
58+
- Key events (`$pageview`, sessions, or the project's top custom events)
59+
- New signups / new users if an identifying event exists
60+
- Notable custom events surfaced by the schema
61+
62+
## 3. Compute per-metric deltas
63+
64+
For each metric: current value, previous value, absolute change, percent change,
65+
and direction (up / down / flat). Flag a metric as notable when the percent
66+
change exceeds ~15% in either direction, or when a value hits zero unexpectedly.
67+
68+
## 4. Summarise (per-metric report)
69+
70+
Open with **I found:** a one-line scope summary (window + project + metric
71+
count). Then a per-metric table:
72+
73+
Metric | Current | Previous | Change | % | Trend
74+
75+
- Trend: use up / down / flat words, never rely on colour alone.
76+
- Call out the 1-3 most notable movers with a short "why it matters" line.
77+
- When a window returns no data, say so explicitly for that metric — never
78+
silently widen the window or invent numbers.
79+
80+
## Traps
81+
82+
- HogQL time ranges are relative; state the absolute window in the report.
83+
- Distinguish "metric returned zero" (real) from "query failed" (report the
84+
failure, do not present it as zero).
85+
- The MCP server exposes 240+ tools; narrow with `name_filter` before calling —
86+
never dump the full listing.
87+
- One bounded query per metric group; event-level scans are expensive.
88+
- `unknown error running this query` almost always means the statement was too
89+
complex (multiple metrics combined, cross-joins, or an unverified property).
90+
Recover by splitting into one simple per-metric query, not by giving up.

integrations/scheduled_agent_bootstrap.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@
33
from __future__ import annotations
44

55
from integrations.github.pr_sweep_runner import run_github_pr_sweep
6+
from integrations.posthog.report_runner import run_posthog_report
67
from integrations.sentry.morning_digest_runner import run_sentry_morning_digest
78
from integrations.sentry.uptime import run_uptime_watch_tick
89
from platform.scheduler.agent_runner import AgentPayload, register_agent_runner
910

1011

1112
def run_scheduled_agent_digest(payload: AgentPayload) -> str:
12-
"""Route by ``payload['source']`` to Sentry digest, uptime watch, or GitHub PR sweep."""
13+
"""Route by ``payload['source']`` to Sentry digest, uptime watch, GitHub PR sweep, or PostHog report."""
1314
source = str(payload.get("source") or "")
1415
if "uptime_watch" in source:
1516
return run_uptime_watch_tick(
@@ -18,6 +19,8 @@ def run_scheduled_agent_digest(payload: AgentPayload) -> str:
1819
)
1920
if "github_pr" in source:
2021
return run_github_pr_sweep(payload)
22+
if "posthog" in source:
23+
return run_posthog_report(payload)
2124
return run_sentry_morning_digest(payload)
2225

2326

platform/scheduler/tasks.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ def build_message(task: ScheduledTask) -> str:
4141
TaskKind.SENTRY_MORNING_DIGEST: _build_sentry_morning_digest,
4242
TaskKind.SENTRY_UPTIME_WATCH: _build_sentry_uptime_watch,
4343
TaskKind.GITHUB_PR_SWEEP: _build_github_pr_sweep,
44+
TaskKind.POSTHOG_METRIC_REPORT: _build_posthog_metric_report,
4445
}
4546
builder = builders.get(task.kind)
4647
if builder is None:
@@ -246,6 +247,24 @@ def _build_github_pr_sweep(task: ScheduledTask) -> str:
246247
) from exc
247248

248249

250+
def _build_posthog_metric_report(task: ScheduledTask) -> str:
251+
"""Build a PostHog per-metric report via the headless posthog-summary skill path."""
252+
try:
253+
safe_params = {k: v for k, v in task.params.items() if k not in _CREDENTIAL_KEYS}
254+
payload = {
255+
"stats_period": "7d",
256+
**safe_params,
257+
"source": "scheduled_posthog_metric_report",
258+
"task_id": task.id,
259+
}
260+
return invoke_agent_runner(payload)
261+
except Exception as exc:
262+
logger.error("PostHog metric report failed for task %s: %s", task.id, exc)
263+
raise RuntimeError(
264+
f"PostHog metric report failed for task {task.id}. Check logs for details."
265+
) from exc
266+
267+
249268
def _build_custom_investigation(task: ScheduledTask) -> str:
250269
"""Run a custom investigation and return the report.
251270

platform/scheduler/types.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ class TaskKind(StrEnum):
2020
SENTRY_MORNING_DIGEST = "sentry_morning_digest"
2121
SENTRY_UPTIME_WATCH = "sentry_uptime_watch"
2222
GITHUB_PR_SWEEP = "github_pr_sweep"
23+
POSTHOG_METRIC_REPORT = "posthog_metric_report"
2324

2425

2526
class TaskStatus(StrEnum):

0 commit comments

Comments
 (0)