feat: v3.4.0 — OpenPanel plugin public release (42 tools)

OpenPanel product analytics plugin fully reviewed, tested, and published.
Works with both self-hosted and cloud (openpanel.dev) instances.

- 42 tools: event tracking, data export, analytics, project/client management
- All tools use public REST APIs (Track, Export, Insights, Manage)
- Client modes: write (tracking), read (analytics), root (full access)
- Service page with description, setup notes, WordPress plugin download
- Dynamic URL hints in Add Site form
- 62 unit tests
- ENABLED_PLUGINS default now includes openpanel

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-31 17:48:12 +02:00
parent 68c84b3ece
commit 9905a28eb0
33 changed files with 1672 additions and 3207 deletions

View File

@@ -1,7 +1,13 @@
"""Export Handler - OpenPanel data export operations (10 tools)
"""Export Handler - OpenPanel data export and analytics operations (10 tools).
Uses REST APIs:
- Export API (GET /export/events, /export/charts) for raw data export
- Insights API (GET /insights/:projectId/*) for analytics queries
Note: project_id is optional if configured in environment variables.
When not provided, the default project_id from OPENPANEL_SITE1_PROJECT_ID is used.
Requires 'read' or 'root' mode client for Export API.
"""
import json
@@ -12,12 +18,12 @@ from plugins.openpanel.handlers.utils import get_project_id as _get_project_id
def get_tool_specifications() -> list[dict[str, Any]]:
"""Return tool specifications for ToolGenerator (10 tools)"""
"""Return tool specifications for ToolGenerator (10 tools)."""
return [
{
"name": "export_events",
"method_name": "export_events",
"description": "Export raw event data with filters and pagination. Returns individual event records. Note: project_id is optional if configured in environment.",
"description": "Export raw event data with filters and pagination. Returns individual event records. Requires 'read' or 'root' mode client.",
"schema": {
"type": "object",
"properties": {
@@ -56,11 +62,22 @@ def get_tool_specifications() -> list[dict[str, Any]]:
"anyOf": [
{
"type": "array",
"items": {"type": "string", "enum": ["profile", "meta"]},
"items": {
"type": "string",
"enum": [
"profile",
"meta",
"properties",
"region",
"device",
"referrer",
"revenue",
],
},
},
{"type": "null"},
],
"description": "Additional data to include (profile, meta)",
"description": "Additional data to include (profile, meta, properties, region, device, referrer, revenue)",
},
},
"required": [],
@@ -70,7 +87,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
{
"name": "export_events_csv",
"method_name": "export_events_csv",
"description": "Export events as CSV-formatted data for spreadsheet analysis. Note: project_id is optional if configured in environment.",
"description": "Export events as CSV-formatted data for spreadsheet analysis. Requires 'read' or 'root' mode client.",
"schema": {
"type": "object",
"properties": {
@@ -103,7 +120,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
{
"name": "export_chart_data",
"method_name": "export_chart_data",
"description": "Export aggregated chart data with time series and breakdowns. Note: project_id is optional if configured in environment.",
"description": "Export aggregated chart data with time series and breakdowns. Requires 'read' or 'root' mode client.",
"schema": {
"type": "object",
"properties": {
@@ -150,7 +167,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
"date_range": {
"type": "string",
"description": "Date range. Common values: 30min, lastHour, today, yesterday, 1d, 3d, 7d, 14d, 30d, 60d, 90d, 6m, 12m, monthToDate, lastMonth, yearToDate, lastYear, all. Can also use custom ranges like '2024-01-01 to 2024-12-31'",
"description": "Date range (30min, today, 7d, 30d, 6m, 12m, etc.)",
"default": "30d",
},
"breakdowns": {
@@ -188,7 +205,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
{
"name": "get_event_count",
"method_name": "get_event_count",
"description": "Get total event count with optional filters. Note: project_id is optional if configured in environment.",
"description": "Get total event count with optional filters via Export API.",
"schema": {
"type": "object",
"properties": {
@@ -202,7 +219,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
"date_range": {
"type": "string",
"description": "Date range. Common: today, yesterday, 7d, 14d, 30d, 60d, 90d, 6m, 12m, monthToDate, yearToDate, all",
"description": "Date range (today, 7d, 30d, 6m, 12m, etc.)",
"default": "30d",
},
},
@@ -213,7 +230,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
{
"name": "get_unique_users",
"method_name": "get_unique_users",
"description": "Get unique user/visitor count for a time period. Note: project_id is optional if configured in environment.",
"description": "Get unique user/visitor count for a time period via Insights API.",
"schema": {
"type": "object",
"properties": {
@@ -223,7 +240,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
"date_range": {
"type": "string",
"description": "Date range. Common: today, yesterday, 7d, 14d, 30d, 60d, 90d, 6m, 12m, monthToDate, yearToDate, all",
"description": "Date range (today, 7d, 30d, 6m, 12m, etc.)",
"default": "30d",
},
},
@@ -234,7 +251,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
{
"name": "get_page_views",
"method_name": "get_page_views",
"description": "Get page view statistics over time. Note: project_id is optional if configured in environment.",
"description": "Get page view statistics via Insights API.",
"schema": {
"type": "object",
"properties": {
@@ -244,15 +261,9 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
"date_range": {
"type": "string",
"description": "Date range. Common: today, yesterday, 7d, 14d, 30d, 60d, 90d, 6m, 12m, monthToDate, yearToDate, all",
"description": "Date range (today, 7d, 30d, 6m, 12m, etc.)",
"default": "30d",
},
"interval": {
"type": "string",
"enum": ["hour", "day", "week", "month"],
"description": "Time interval",
"default": "day",
},
},
"required": [],
},
@@ -261,7 +272,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
{
"name": "get_top_pages",
"method_name": "get_top_pages",
"description": "Get top pages by view count. Note: project_id is optional if configured in environment.",
"description": "Get top pages by view count via Insights API.",
"schema": {
"type": "object",
"properties": {
@@ -271,7 +282,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
"date_range": {
"type": "string",
"description": "Date range. Common: today, yesterday, 7d, 14d, 30d, 60d, 90d, 6m, 12m, monthToDate, yearToDate, all",
"description": "Date range (today, 7d, 30d, 6m, 12m, etc.)",
"default": "30d",
},
"limit": {
@@ -287,7 +298,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
{
"name": "get_top_referrers",
"method_name": "get_top_referrers",
"description": "Get top traffic sources/referrers. Note: project_id is optional if configured in environment.",
"description": "Get top traffic sources/referrers via Insights API.",
"schema": {
"type": "object",
"properties": {
@@ -297,7 +308,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
"date_range": {
"type": "string",
"description": "Date range. Common: today, yesterday, 7d, 14d, 30d, 60d, 90d, 6m, 12m, monthToDate, yearToDate, all",
"description": "Date range (today, 7d, 30d, 6m, 12m, etc.)",
"default": "30d",
},
"limit": {
@@ -313,7 +324,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
{
"name": "get_geo_data",
"method_name": "get_geo_data",
"description": "Get geographic distribution of users/visitors. Note: project_id is optional if configured in environment.",
"description": "Get geographic distribution of visitors via Insights API.",
"schema": {
"type": "object",
"properties": {
@@ -323,7 +334,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
"date_range": {
"type": "string",
"description": "Date range. Common: today, yesterday, 7d, 14d, 30d, 60d, 90d, 6m, 12m, monthToDate, yearToDate, all",
"description": "Date range (today, 7d, 30d, 6m, 12m, etc.)",
"default": "30d",
},
"breakdown": {
@@ -345,7 +356,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
{
"name": "get_device_data",
"method_name": "get_device_data",
"description": "Get device/browser/OS breakdown of users. Note: project_id is optional if configured in environment.",
"description": "Get device/browser/OS breakdown of visitors via Insights API.",
"schema": {
"type": "object",
"properties": {
@@ -355,7 +366,7 @@ def get_tool_specifications() -> list[dict[str, Any]]:
},
"date_range": {
"type": "string",
"description": "Date range. Common: today, yesterday, 7d, 14d, 30d, 60d, 90d, 6m, 12m, monthToDate, yearToDate, all",
"description": "Date range (today, 7d, 30d, 6m, 12m, etc.)",
"default": "30d",
},
"breakdown": {
@@ -393,11 +404,11 @@ async def export_events(
page: int = 1,
includes: list[str] | None = None,
) -> str:
"""Export raw event data"""
"""Export raw event data via GET /export/events."""
try:
effective_project_id = _get_project_id(client, project_id)
pid = _get_project_id(client, project_id)
result = await client.export_events(
project_id=effective_project_id,
project_id=pid,
event=event,
profile_id=profile_id,
start=start,
@@ -406,18 +417,17 @@ async def export_events(
limit=limit,
includes=includes,
)
events_count = len(result.get("data", [])) if isinstance(result, dict) else 0
meta = result.get("meta", {}) if isinstance(result, dict) else {}
data = result.get("data", []) if isinstance(result, dict) else []
return json.dumps(
{
"success": True,
"project_id": effective_project_id,
"count": events_count,
"page": page,
"limit": limit,
"filters": {"event": event, "profile_id": profile_id, "start": start, "end": end},
"data": result,
"project_id": pid,
"count": len(data),
"total": meta.get("totalCount", len(data)),
"page": meta.get("current", page),
"pages": meta.get("pages", 1),
"data": data,
},
indent=2,
ensure_ascii=False,
@@ -434,20 +444,18 @@ async def export_events_csv(
end: str | None = None,
limit: int = 1000,
) -> str:
"""Export events as CSV-formatted data"""
"""Export events as CSV-formatted data."""
try:
effective_project_id = _get_project_id(client, project_id)
pid = _get_project_id(client, project_id)
result = await client.export_events(
project_id=effective_project_id, event=event, start=start, end=end, limit=limit
project_id=pid, event=event, start=start, end=end, limit=limit
)
events = result.get("data", []) if isinstance(result, dict) else []
if not events:
return json.dumps(
{
"success": True,
"project_id": effective_project_id,
"project_id": pid,
"count": 0,
"csv": "",
"message": "No events found",
@@ -455,28 +463,22 @@ async def export_events_csv(
indent=2,
ensure_ascii=False,
)
# Build CSV
headers = ["timestamp", "name", "profile_id"]
csv_lines = [",".join(headers)]
for event_data in events:
for ev in events:
row = [
str(event_data.get("timestamp", "")),
str(event_data.get("name", "")),
str(event_data.get("profileId", "")),
str(ev.get("createdAt", ev.get("timestamp", ""))),
str(ev.get("name", "")),
str(ev.get("profileId", "")),
]
csv_lines.append(",".join(row))
csv_content = "\n".join(csv_lines)
return json.dumps(
{
"success": True,
"project_id": effective_project_id,
"project_id": pid,
"count": len(events),
"format": "csv",
"csv": csv_content,
"csv": "\n".join(csv_lines),
},
indent=2,
ensure_ascii=False,
@@ -494,22 +496,22 @@ async def export_chart_data(
breakdowns: list[str] | None = None,
previous: bool = False,
) -> str:
"""Export aggregated chart data"""
"""Export aggregated chart data via GET /export/charts."""
try:
effective_project_id = _get_project_id(client, project_id)
pid = _get_project_id(client, project_id)
bd = [{"name": b} for b in breakdowns] if breakdowns else None
result = await client.export_charts(
project_id=effective_project_id,
project_id=pid,
events=events,
interval=interval,
date_range=date_range,
breakdowns=breakdowns,
breakdowns=bd,
previous=previous,
)
return json.dumps(
{
"success": True,
"project_id": effective_project_id,
"project_id": pid,
"events": [e.get("name") for e in events],
"interval": interval,
"date_range": date_range,
@@ -529,29 +531,33 @@ async def get_event_count(
event: str | None = None,
date_range: str = "30d",
) -> str:
"""Get total event count"""
"""Get total event count via Export charts API."""
try:
effective_project_id = _get_project_id(client, project_id)
pid = _get_project_id(client, project_id)
events_config = [{"name": event if event else "*", "segment": "event"}]
result = await client.export_charts(
project_id=effective_project_id,
project_id=pid,
events=events_config,
interval="day",
date_range=date_range,
)
# Sum up the counts
# Extract total from chart response
total = 0
if isinstance(result, dict) and "data" in result:
for point in result.get("data", []):
total += point.get("count", 0)
if isinstance(result, dict):
series = result.get("series", [])
if series:
for s in series:
for point in s.get("data", []):
total += point.get("count", 0)
# Alternative: check metrics
metrics = result.get("metrics", {})
if metrics.get("current", {}).get("value"):
total = metrics["current"]["value"]
return json.dumps(
{
"success": True,
"project_id": effective_project_id,
"event": event if event else "all events",
"project_id": pid,
"event": event or "all events",
"date_range": date_range,
"total_count": total,
},
@@ -565,47 +571,12 @@ async def get_event_count(
async def get_unique_users(
client: OpenPanelClient, project_id: str | None = None, date_range: str = "30d"
) -> str:
"""Get unique user count using tRPC overview.stats"""
"""Get unique user count via Insights metrics API."""
try:
effective_project_id = _get_project_id(client, project_id)
# Use overview.stats to get visitor count
result = await client.get_overview_stats(
project_id=effective_project_id, date_range=date_range
)
# Check for errors
if isinstance(result, dict) and "error" in result:
return json.dumps(
{
"success": False,
"project_id": effective_project_id,
"date_range": date_range,
"error": result.get("error"),
"note": "tRPC overview.stats endpoint may require authentication",
},
indent=2,
ensure_ascii=False,
)
# Extract unique visitors from overview stats
unique_users = 0
if isinstance(result, dict):
# Try different possible field names for visitors
unique_users = (
result.get("visitors", 0)
or result.get("uniqueVisitors", 0)
or result.get("current", {}).get("visitors", 0)
)
pid = _get_project_id(client, project_id)
result = await client.get_overview_stats(project_id=pid, date_range=date_range)
return json.dumps(
{
"success": True,
"project_id": effective_project_id,
"date_range": date_range,
"unique_users": unique_users,
"raw_stats": result if isinstance(result, dict) else None,
},
{"success": True, "project_id": pid, "date_range": date_range, "stats": result},
indent=2,
ensure_ascii=False,
)
@@ -617,51 +588,13 @@ async def get_page_views(
client: OpenPanelClient,
project_id: str | None = None,
date_range: str = "30d",
interval: str = "day",
) -> str:
"""Get page view statistics using tRPC overview.stats"""
"""Get page view statistics via Insights metrics API."""
try:
effective_project_id = _get_project_id(client, project_id)
# Use overview.stats to get pageview count
result = await client.get_overview_stats(
project_id=effective_project_id, date_range=date_range
)
# Check for errors
if isinstance(result, dict) and "error" in result:
return json.dumps(
{
"success": False,
"project_id": effective_project_id,
"date_range": date_range,
"interval": interval,
"error": result.get("error"),
"note": "tRPC overview.stats endpoint may require authentication",
},
indent=2,
ensure_ascii=False,
)
# Extract pageview count from overview stats
total_page_views = 0
if isinstance(result, dict):
# Try different possible field names for pageviews
total_page_views = (
result.get("pageviews", 0)
or result.get("pageViews", 0)
or result.get("current", {}).get("pageviews", 0)
)
pid = _get_project_id(client, project_id)
result = await client.get_overview_stats(project_id=pid, date_range=date_range)
return json.dumps(
{
"success": True,
"project_id": effective_project_id,
"date_range": date_range,
"interval": interval,
"total_page_views": total_page_views,
"raw_stats": result if isinstance(result, dict) else None,
},
{"success": True, "project_id": pid, "date_range": date_range, "stats": result},
indent=2,
ensure_ascii=False,
)
@@ -672,44 +605,12 @@ async def get_page_views(
async def get_top_pages(
client: OpenPanelClient, project_id: str | None = None, date_range: str = "30d", limit: int = 10
) -> str:
"""Get top pages by view count using tRPC overview.topPages"""
"""Get top pages via Insights pages API."""
try:
effective_project_id = _get_project_id(client, project_id)
# Use the new tRPC overview.topPages endpoint
result = await client.get_top_pages(
project_id=effective_project_id, date_range=date_range, mode="page"
)
# Check for errors
if isinstance(result, dict) and "error" in result:
return json.dumps(
{
"success": False,
"project_id": effective_project_id,
"date_range": date_range,
"error": result.get("error"),
"note": "tRPC overview.topPages endpoint may require authentication",
},
indent=2,
ensure_ascii=False,
)
# Transform tRPC response to our format
pages = []
if isinstance(result, list):
pages = result[:limit]
elif isinstance(result, dict) and "data" in result:
pages = result.get("data", [])[:limit]
pid = _get_project_id(client, project_id)
result = await client.get_top_pages(project_id=pid, date_range=date_range, limit=limit)
return json.dumps(
{
"success": True,
"project_id": effective_project_id,
"date_range": date_range,
"count": len(pages),
"top_pages": pages,
},
{"success": True, "project_id": pid, "date_range": date_range, "data": result},
indent=2,
ensure_ascii=False,
)
@@ -720,54 +621,12 @@ async def get_top_pages(
async def get_top_referrers(
client: OpenPanelClient, project_id: str | None = None, date_range: str = "30d", limit: int = 10
) -> str:
"""Get top traffic sources using chart.chart with referrer breakdown"""
"""Get top traffic sources via Insights breakdown API."""
try:
effective_project_id = _get_project_id(client, project_id)
# Use chart.chart with referrer breakdown
result = await client.get_top_sources(
project_id=effective_project_id, date_range=date_range, limit=limit
)
# Check for errors
if isinstance(result, dict) and "error" in result:
return json.dumps(
{
"success": False,
"project_id": effective_project_id,
"date_range": date_range,
"error": result.get("error"),
"note": "tRPC chart.chart endpoint may require authentication",
},
indent=2,
ensure_ascii=False,
)
# Extract referrers from chart.chart response
# Response format: {series: [{data: [...], breakdowns: {referrer: [...]}}]}
referrers = []
if isinstance(result, dict):
series = result.get("series", [])
if series:
for serie in series:
breakdown_data = serie.get("breakdowns", {}).get("referrer", [])
for item in breakdown_data[:limit]:
referrers.append(
{
"referrer": item.get("label", item.get("name", "")),
"count": item.get("count", 0),
"percentage": item.get("percentage", 0),
}
)
pid = _get_project_id(client, project_id)
result = await client.get_top_sources(project_id=pid, date_range=date_range, limit=limit)
return json.dumps(
{
"success": True,
"project_id": effective_project_id,
"date_range": date_range,
"count": len(referrers),
"top_referrers": referrers,
},
{"success": True, "project_id": pid, "date_range": date_range, "data": result},
indent=2,
ensure_ascii=False,
)
@@ -782,55 +641,19 @@ async def get_geo_data(
breakdown: str = "country",
limit: int = 10,
) -> str:
"""Get geographic distribution using chart.chart with country/city/region breakdown"""
"""Get geographic distribution via Insights breakdown API."""
try:
effective_project_id = _get_project_id(client, project_id)
# Use chart.chart with geographic breakdown
pid = _get_project_id(client, project_id)
result = await client.get_top_locations(
project_id=effective_project_id, date_range=date_range, breakdown=breakdown, limit=limit
project_id=pid, date_range=date_range, breakdown=breakdown, limit=limit
)
# Check for errors
if isinstance(result, dict) and "error" in result:
return json.dumps(
{
"success": False,
"project_id": effective_project_id,
"date_range": date_range,
"breakdown": breakdown,
"error": result.get("error"),
"note": "tRPC chart.chart endpoint may require authentication",
},
indent=2,
ensure_ascii=False,
)
# Extract locations from chart.chart response
# Response format: {series: [{data: [...], breakdowns: {country: [...]}}]}
locations = []
if isinstance(result, dict):
series = result.get("series", [])
if series:
for serie in series:
breakdown_data = serie.get("breakdowns", {}).get(breakdown, [])
for item in breakdown_data[:limit]:
locations.append(
{
"location": item.get("label", item.get("name", "")),
"count": item.get("count", 0),
"percentage": item.get("percentage", 0),
}
)
return json.dumps(
{
"success": True,
"project_id": effective_project_id,
"project_id": pid,
"date_range": date_range,
"breakdown": breakdown,
"count": len(locations),
"locations": locations,
"data": result,
},
indent=2,
ensure_ascii=False,
@@ -846,64 +669,26 @@ async def get_device_data(
breakdown: str = "device",
limit: int = 10,
) -> str:
"""Get device/browser/OS breakdown using chart.chart with appropriate breakdown"""
"""Get device/browser/OS breakdown via Insights breakdown API."""
try:
effective_project_id = _get_project_id(client, project_id)
# Use appropriate chart.chart breakdown based on breakdown type
pid = _get_project_id(client, project_id)
if breakdown == "browser":
result = await client.get_top_browsers(
project_id=effective_project_id, date_range=date_range, limit=limit
project_id=pid, date_range=date_range, limit=limit
)
elif breakdown == "os":
result = await client.get_top_os(
project_id=effective_project_id, date_range=date_range, limit=limit
)
else: # device (default)
result = await client.get_top_os(project_id=pid, date_range=date_range, limit=limit)
else:
result = await client.get_top_devices(
project_id=effective_project_id, date_range=date_range, limit=limit
project_id=pid, date_range=date_range, limit=limit
)
# Check for errors
if isinstance(result, dict) and "error" in result:
return json.dumps(
{
"success": False,
"project_id": effective_project_id,
"date_range": date_range,
"breakdown": breakdown,
"error": result.get("error"),
"note": "tRPC chart.chart endpoint may require authentication",
},
indent=2,
ensure_ascii=False,
)
# Extract device data from chart.chart response
# Response format: {series: [{data: [...], breakdowns: {device: [...]}}]}
devices = []
if isinstance(result, dict):
series = result.get("series", [])
if series:
for serie in series:
breakdown_data = serie.get("breakdowns", {}).get(breakdown, [])
for item in breakdown_data[:limit]:
devices.append(
{
"name": item.get("label", item.get("name", "")),
"count": item.get("count", 0),
"percentage": item.get("percentage", 0),
}
)
return json.dumps(
{
"success": True,
"project_id": effective_project_id,
"project_id": pid,
"date_range": date_range,
"breakdown": breakdown,
"count": len(devices),
"devices": devices,
"data": result,
},
indent=2,
ensure_ascii=False,