Files
mcphub/plugins/openpanel/client.py
airano-ir 9905a28eb0 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>
2026-03-31 17:48:12 +02:00

641 lines
23 KiB
Python

"""
OpenPanel API Client (Self-Hosted).
Handles all HTTP communication with OpenPanel Self-Hosted APIs.
Public APIs (client_id + client_secret auth):
- Track API (/track) - Event ingestion (POST)
- Export API (/export/events, /export/charts) - Data export (GET)
- Insights API (/insights/:projectId/*) - Analytics queries (GET)
- Manage API (/manage/projects, /manage/clients) - CRUD management
- Profile API (/profile) - Profile updates (POST)
- Health API (/healthcheck) - Instance health (GET)
All public API endpoints use openpanel-client-id + openpanel-client-secret headers.
The client must have appropriate mode: 'write' for tracking, 'read' for export, 'root' for manage.
Note: The tRPC API (/trpc/*) requires dashboard session cookies and is NOT used.
"""
import logging
from typing import Any
import aiohttp
class OpenPanelClient:
"""
OpenPanel Self-Hosted API client.
Uses the public REST APIs (Track, Export, Insights, Manage, Profile, Health).
Authentication via openpanel-client-id and openpanel-client-secret headers.
Client modes:
- write: Can send events (Track API)
- read: Can read data (Export API, Insights API)
- root: Full access (all APIs including Manage)
"""
def __init__(
self,
base_url: str,
client_id: str,
client_secret: str,
project_id: str | None = None,
organization_id: str | None = None,
):
"""
Initialize OpenPanel API client.
Args:
base_url: OpenPanel instance URL (e.g., https://analytics.example.com)
client_id: Client ID for authentication
client_secret: Client Secret for authentication
project_id: Default project ID for operations (optional)
organization_id: Organization/Workspace ID (optional)
"""
self.base_url = base_url.rstrip("/")
self.client_id = client_id
self.client_secret = client_secret
self.default_project_id = project_id
self.default_organization_id = organization_id
self.logger = logging.getLogger(f"OpenPanelClient.{base_url}")
def _get_headers(
self,
client_ip: str | None = None,
user_agent: str | None = None,
additional_headers: dict | None = None,
) -> dict[str, str]:
"""Get request headers with authentication."""
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"openpanel-client-id": self.client_id,
"openpanel-client-secret": self.client_secret,
}
if client_ip:
headers["x-client-ip"] = client_ip
if user_agent:
headers["user-agent"] = user_agent
if additional_headers:
headers.update(additional_headers)
return headers
async def request(
self,
method: str,
endpoint: str,
params: dict | None = None,
json_data: dict | None = None,
headers_override: dict | None = None,
client_ip: str | None = None,
user_agent: str | None = None,
) -> Any:
"""
Make authenticated request to OpenPanel API.
Args:
method: HTTP method
endpoint: API endpoint (with leading /)
params: Query parameters
json_data: JSON body data
headers_override: Override/add headers
client_ip: Client IP for geo tracking
user_agent: User agent for device info
Returns:
API response
Raises:
Exception: On API errors
"""
url = f"{self.base_url}{endpoint}"
headers = self._get_headers(client_ip, user_agent, headers_override)
if params:
params = {k: v for k, v in params.items() if v is not None}
if json_data:
json_data = {k: v for k, v in json_data.items() if v is not None}
self.logger.debug(f"{method} {url}")
async with aiohttp.ClientSession() as session:
kwargs: dict[str, Any] = {
"method": method,
"url": url,
"headers": headers,
}
if params:
kwargs["params"] = params
if json_data:
kwargs["json"] = json_data
async with session.request(**kwargs) as response:
self.logger.debug(f"Response status: {response.status}")
if response.status == 204:
return {"success": True}
try:
response_data = await response.json()
except Exception:
response_text = await response.text()
if response.status >= 400:
raise Exception(
f"OpenPanel API error (status {response.status}): {response_text}"
)
return {"success": True, "message": response_text}
if response.status >= 400:
error_msg = self._extract_error_message(response_data, response.status)
raise Exception(f"OpenPanel API error (status {response.status}): {error_msg}")
return response_data
def _extract_error_message(self, response_data: Any, status_code: int = 0) -> str:
"""Extract error message from various response formats with helpful hints."""
message = ""
if isinstance(response_data, dict):
if "message" in response_data:
message = response_data["message"]
elif "error" in response_data:
message = response_data["error"]
elif "msg" in response_data:
message = response_data["msg"]
else:
message = str(response_data)
else:
message = str(response_data)
hints = []
if status_code == 401 or status_code == 403:
hints.append("Verify client_id and client_secret are correct and have appropriate mode")
elif status_code == 404:
hints.append("Verify the project_id exists in OpenPanel")
elif status_code == 429:
hints.append("Rate limited (100 req/10s). Retry with backoff")
if hints:
return f"{message} (Hint: {'; '.join(hints)})"
return message
# =====================
# TRACK API (/track)
# =====================
async def track(
self,
event_type: str,
payload: dict[str, Any],
client_ip: str | None = None,
user_agent: str | None = None,
) -> dict[str, Any]:
"""
Send tracking request to POST /track.
Supported types: track, identify, increment, decrement, group, assign_group.
Args:
event_type: Type of tracking operation
payload: Event payload
client_ip: Client IP for geo tracking
user_agent: User agent for device info
"""
data = {"type": event_type, "payload": payload}
return await self.request(
"POST", "/track", json_data=data, client_ip=client_ip, user_agent=user_agent
)
async def track_event(
self,
name: str,
properties: dict[str, Any] | None = None,
profile_id: str | None = None,
groups: list[str] | None = None,
timestamp: str | None = None,
client_ip: str | None = None,
user_agent: str | None = None,
) -> dict[str, Any]:
"""Track a custom event."""
payload: dict[str, Any] = {"name": name}
if properties:
payload["properties"] = properties
if profile_id:
payload["profileId"] = profile_id
if groups:
payload["groups"] = groups
if timestamp:
payload["timestamp"] = timestamp
return await self.track("track", payload, client_ip, user_agent)
async def identify_user(
self,
profile_id: str,
properties: dict[str, Any] | None = None,
first_name: str | None = None,
last_name: str | None = None,
email: str | None = None,
client_ip: str | None = None,
user_agent: str | None = None,
) -> dict[str, Any]:
"""Identify a user with profile data."""
payload: dict[str, Any] = {"profileId": profile_id}
if first_name:
payload["firstName"] = first_name
if last_name:
payload["lastName"] = last_name
if email:
payload["email"] = email
if properties:
payload["properties"] = properties
return await self.track("identify", payload, client_ip, user_agent)
async def increment_property(
self,
profile_id: str,
property_name: str,
value: int = 1,
client_ip: str | None = None,
user_agent: str | None = None,
) -> dict[str, Any]:
"""Increment a numeric property on a profile."""
payload = {"profileId": profile_id, "property": property_name, "value": value}
return await self.track("increment", payload, client_ip, user_agent)
async def decrement_property(
self,
profile_id: str,
property_name: str,
value: int = 1,
client_ip: str | None = None,
user_agent: str | None = None,
) -> dict[str, Any]:
"""Decrement a numeric property on a profile."""
payload = {"profileId": profile_id, "property": property_name, "value": value}
return await self.track("decrement", payload, client_ip, user_agent)
async def track_group(
self,
group_id: str,
group_type: str,
name: str,
properties: dict[str, Any] | None = None,
client_ip: str | None = None,
user_agent: str | None = None,
) -> dict[str, Any]:
"""Create or update a group (e.g., company, workspace)."""
payload: dict[str, Any] = {"id": group_id, "type": group_type, "name": name}
if properties:
payload["properties"] = properties
return await self.track("group", payload, client_ip, user_agent)
async def assign_group(
self,
group_ids: list[str],
profile_id: str | None = None,
client_ip: str | None = None,
user_agent: str | None = None,
) -> dict[str, Any]:
"""Assign a user to one or more groups."""
payload: dict[str, Any] = {"groupIds": group_ids}
if profile_id:
payload["profileId"] = profile_id
return await self.track("assign_group", payload, client_ip, user_agent)
# =====================
# EXPORT API (/export)
# Requires 'read' or 'root' mode client
# =====================
async def export_events(
self,
project_id: str,
event: str | list[str] | None = None,
profile_id: str | None = None,
start: str | None = None,
end: str | None = None,
page: int = 1,
limit: int = 50,
includes: list[str] | None = None,
) -> dict[str, Any]:
"""
Export raw event data via GET /export/events.
Args:
project_id: Project ID to export from
event: Event name(s) to filter
profile_id: Filter by profile ID
start: Start date (YYYY-MM-DD)
end: End date (YYYY-MM-DD)
page: Page number
limit: Events per page (max 1000)
includes: Additional data fields (profile, meta, properties, etc.)
"""
params: dict[str, Any] = {"projectId": project_id, "page": page, "limit": limit}
if event:
params["event"] = event if isinstance(event, str) else ",".join(event)
if profile_id:
params["profileId"] = profile_id
if start:
params["start"] = start
if end:
params["end"] = end
if includes:
params["includes"] = ",".join(includes)
return await self.request("GET", "/export/events", params=params)
async def export_charts(
self,
project_id: str,
events: list[dict[str, Any]] | None = None,
series: list[dict[str, Any]] | None = None,
interval: str = "day",
date_range: str = "30d",
breakdowns: list[dict[str, Any]] | None = None,
previous: bool = False,
start_date: str | None = None,
end_date: str | None = None,
) -> dict[str, Any]:
"""
Export aggregated chart data via GET /export/charts.
Args:
project_id: Project ID
events: Legacy event configs (mapped to series internally)
series: Series configurations with name, segment, filters
interval: Time interval (minute, hour, day, week, month)
date_range: Date range (30min, today, 7d, 30d, etc.)
breakdowns: Breakdown dimensions [{name: "country"}, ...]
previous: Include previous period for comparison
start_date: Custom start date (YYYY-MM-DD)
end_date: Custom end date (YYYY-MM-DD)
"""
import json
# Build series from events (backward compat) or use series directly
series_data = series or []
if not series_data and events:
for idx, event in enumerate(events):
item: dict[str, Any] = {
"id": str(idx),
"name": event.get("name", "*"),
"segment": event.get("segment", "event"),
"filters": event.get("filters", []),
}
if "property" in event:
item["property"] = event["property"]
series_data.append(item)
params: dict[str, Any] = {
"projectId": project_id,
"interval": interval,
"range": date_range,
"series": json.dumps(series_data),
"previous": str(previous).lower(),
}
if breakdowns:
params["breakdowns"] = json.dumps(breakdowns)
if start_date:
params["startDate"] = start_date
if end_date:
params["endDate"] = end_date
return await self.request("GET", "/export/charts", params=params)
# =====================
# INSIGHTS API (/insights/:projectId)
# Requires 'read' or 'root' mode client
# =====================
async def get_overview_stats(
self,
project_id: str,
date_range: str = "30d",
start_date: str | None = None,
end_date: str | None = None,
filters: list[dict] | None = None,
) -> dict[str, Any]:
"""
Get overview statistics via GET /insights/:projectId/metrics.
Returns visitors, page views, sessions, bounce rate, etc.
"""
import json
params: dict[str, Any] = {"range": date_range}
if start_date:
params["startDate"] = start_date
if end_date:
params["endDate"] = end_date
if filters:
params["filters"] = json.dumps(filters)
return await self.request("GET", f"/insights/{project_id}/metrics", params=params)
async def get_live_visitors(self, project_id: str) -> dict[str, Any]:
"""Get live/realtime visitor count via GET /insights/:projectId/live."""
return await self.request("GET", f"/insights/{project_id}/live")
async def get_top_pages(
self,
project_id: str,
date_range: str = "30d",
start_date: str | None = None,
end_date: str | None = None,
limit: int = 10,
cursor: int | None = None,
filters: list[dict] | None = None,
) -> Any:
"""Get top pages via GET /insights/:projectId/pages."""
import json
params: dict[str, Any] = {"range": date_range, "limit": limit}
if start_date:
params["startDate"] = start_date
if end_date:
params["endDate"] = end_date
if cursor is not None:
params["cursor"] = cursor
if filters:
params["filters"] = json.dumps(filters)
return await self.request("GET", f"/insights/{project_id}/pages", params=params)
async def get_insights_breakdown(
self,
project_id: str,
column: str,
date_range: str = "30d",
start_date: str | None = None,
end_date: str | None = None,
limit: int = 10,
cursor: int | None = None,
filters: list[dict] | None = None,
) -> Any:
"""
Get breakdown data via GET /insights/:projectId/:column.
Supported columns: referrer, referrer_name, referrer_type,
utm_source, utm_medium, utm_campaign, utm_term, utm_content,
device, browser, browser_version, os, os_version, brand, model,
country, region, city.
"""
import json
params: dict[str, Any] = {"range": date_range, "limit": limit}
if start_date:
params["startDate"] = start_date
if end_date:
params["endDate"] = end_date
if cursor is not None:
params["cursor"] = cursor
if filters:
params["filters"] = json.dumps(filters)
return await self.request("GET", f"/insights/{project_id}/{column}", params=params)
# Convenience wrappers for common breakdowns
async def get_top_sources(
self, project_id: str, date_range: str = "30d", limit: int = 10, **kwargs: Any
) -> Any:
"""Get top traffic sources/referrers."""
return await self.get_insights_breakdown(
project_id, "referrer_name", date_range, limit=limit, **kwargs
)
async def get_top_locations(
self,
project_id: str,
date_range: str = "30d",
breakdown: str = "country",
limit: int = 10,
**kwargs: Any,
) -> Any:
"""Get top locations (country, region, or city)."""
return await self.get_insights_breakdown(
project_id, breakdown, date_range, limit=limit, **kwargs
)
async def get_top_devices(
self, project_id: str, date_range: str = "30d", limit: int = 10, **kwargs: Any
) -> Any:
"""Get top devices."""
return await self.get_insights_breakdown(
project_id, "device", date_range, limit=limit, **kwargs
)
async def get_top_browsers(
self, project_id: str, date_range: str = "30d", limit: int = 10, **kwargs: Any
) -> Any:
"""Get top browsers."""
return await self.get_insights_breakdown(
project_id, "browser", date_range, limit=limit, **kwargs
)
async def get_top_os(
self, project_id: str, date_range: str = "30d", limit: int = 10, **kwargs: Any
) -> Any:
"""Get top operating systems."""
return await self.get_insights_breakdown(
project_id, "os", date_range, limit=limit, **kwargs
)
# =====================
# MANAGE API (/manage)
# Requires 'root' mode client
# =====================
async def list_projects(self) -> Any:
"""List all projects via GET /manage/projects."""
return await self.request("GET", "/manage/projects")
async def get_project(self, project_id: str) -> Any:
"""Get project details via GET /manage/projects/:id."""
return await self.request("GET", f"/manage/projects/{project_id}")
async def create_project(self, data: dict[str, Any]) -> Any:
"""Create a new project via POST /manage/projects."""
return await self.request("POST", "/manage/projects", json_data=data)
async def update_project(self, project_id: str, data: dict[str, Any]) -> Any:
"""Update a project via PATCH /manage/projects/:id."""
return await self.request("PATCH", f"/manage/projects/{project_id}", json_data=data)
async def delete_project(self, project_id: str) -> Any:
"""Delete a project via DELETE /manage/projects/:id."""
return await self.request("DELETE", f"/manage/projects/{project_id}")
async def list_clients(self) -> Any:
"""List all API clients via GET /manage/clients."""
return await self.request("GET", "/manage/clients")
async def get_client(self, client_id: str) -> Any:
"""Get client details via GET /manage/clients/:id."""
return await self.request("GET", f"/manage/clients/{client_id}")
async def create_client(self, data: dict[str, Any]) -> Any:
"""Create a new API client via POST /manage/clients."""
return await self.request("POST", "/manage/clients", json_data=data)
async def update_client(self, client_id: str, data: dict[str, Any]) -> Any:
"""Update a client via PATCH /manage/clients/:id."""
return await self.request("PATCH", f"/manage/clients/{client_id}", json_data=data)
async def delete_client(self, client_id: str) -> Any:
"""Delete a client via DELETE /manage/clients/:id."""
return await self.request("DELETE", f"/manage/clients/{client_id}")
# =====================
# HEALTH API
# =====================
async def health_check(self) -> dict[str, Any]:
"""Check OpenPanel instance health via GET /healthcheck."""
results: dict[str, Any] = {"healthy": True, "services": {}}
# Test health endpoint
try:
await self.request("GET", "/healthcheck")
results["services"]["api"] = "ok"
except Exception as e:
error_str = str(e)
if "200" in error_str or "ok" in error_str.lower():
results["services"]["api"] = "ok"
else:
results["services"]["api"] = f"error: {error_str}"
results["healthy"] = False
# Test Track API
try:
await self.request(
"POST",
"/track",
json_data={"type": "track", "payload": {"name": "__health_check__"}},
)
results["services"]["track_api"] = "ok"
except Exception as e:
error_str = str(e)
if "400" in error_str or "invalid" in error_str.lower():
results["services"]["track_api"] = "ok (responding)"
else:
results["services"]["track_api"] = f"error: {error_str}"
results["healthy"] = False
return results
async def get_instance_info(self) -> dict[str, Any]:
"""Get OpenPanel instance information."""
info: dict[str, Any] = {
"url": self.base_url,
"client_id": self.client_id[:8] + "..." if len(self.client_id) > 8 else self.client_id,
"type": "openpanel",
"version": "self-hosted",
}
if self.default_project_id:
info["default_project_id"] = self.default_project_id
if self.default_organization_id:
info["default_organization_id"] = self.default_organization_id
return info