Skip to content

Commit 56fc4aa

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 56fc4aa

14 files changed

Lines changed: 789 additions & 7 deletions

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"

docs/posthog-mcp.mdx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,3 +113,46 @@ opensre integrations verify posthog_mcp
113113
```
114114

115115
A successful check connects to the MCP server and reports how many tools it discovered. If it fails, the most common cause is a missing or invalid personal API key — confirm the key was created with the `MCP Server` preset and that outbound HTTPS to `mcp.posthog.com` is allowed.
116+
117+
## Metric report (scheduled)
118+
119+
Deliver a per-metric PostHog analytics summary to Slack or Telegram, on demand or on a schedule. This uses the headless **posthog-summary** skill path (schema discovery, then one bounded HogQL query per metric), not the investigation pipeline or generic `opensre cron` kinds. It requires the PostHog **MCP** integration above — the REST `posthog` integration alone cannot serve it.
120+
121+
| Command | What it does |
122+
| --- | --- |
123+
| `opensre posthog report run` | Run once and print the per-metric report to stdout |
124+
| `opensre posthog report run --period 30d --metrics "active users,pageviews"` | Run once for a custom window and metric focus |
125+
| `opensre posthog report schedule add` | Schedule recurring delivery (cron + provider + chat) |
126+
| `opensre posthog report schedule list` | List PostHog report schedules |
127+
| `opensre posthog report schedule run TASK_ID` | Run a scheduled report immediately |
128+
| `opensre posthog report schedule remove TASK_ID` | Remove a schedule |
129+
130+
Example — every Monday at 08:00 London time to Telegram:
131+
132+
```bash
133+
opensre posthog report schedule add \
134+
--cron "0 8 * * 1" \
135+
--tz Europe/London \
136+
--provider telegram \
137+
--chat-id "-1001234567890" \
138+
--period 7d
139+
```
140+
141+
Example — same schedule to a Slack channel (`C…` member/channel id):
142+
143+
```bash
144+
opensre posthog report schedule add \
145+
--cron "0 8 * * 1" \
146+
--tz Europe/London \
147+
--provider slack \
148+
--chat-id C0123ABCD \
149+
--period 7d
150+
```
151+
152+
`--period` accepts a relative window (`24h`, `7d`, `30d`; default `7d`). Optional `--metrics` narrows the report to a comma-separated set instead of the default metric set.
153+
154+
The gateway daemon picks up scheduled reports automatically when it is running. If the LLM is unavailable, the run fails with an error (no deterministic fallback).
155+
156+
<Note>
157+
The report compares the current window against the previous comparable window per metric. When a metric returns no data for the window it is reported explicitly as zero-ingestion, not silently widened or fabricated.
158+
</Note>
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

0 commit comments

Comments
 (0)