Merge pull request #4239 from Ttian18/feat/tina-google-analytics-tool
[Integration]: Google Analytics - Website Traffic & Marketing Performance #3727
This commit is contained in:
@@ -31,6 +31,7 @@ dependencies = [
|
||||
"litellm>=1.81.0",
|
||||
"dnspython>=2.4.0",
|
||||
"resend>=2.0.0",
|
||||
"google-analytics-data>=0.18.0",
|
||||
"framework",
|
||||
"stripe>=14.3.0",
|
||||
"arxiv>=2.1.0",
|
||||
|
||||
@@ -41,6 +41,7 @@ Credential categories:
|
||||
- github.py: GitHub API credentials
|
||||
- hubspot.py: HubSpot CRM credentials
|
||||
- slack.py: Slack workspace credentials
|
||||
- google_analytics.py: Google Analytics credentials
|
||||
- google_maps.py: Google Maps Platform credentials
|
||||
- calcom.py: Cal.com scheduling API credentials
|
||||
|
||||
@@ -63,6 +64,7 @@ from .discord import DISCORD_CREDENTIALS
|
||||
from .email import EMAIL_CREDENTIALS
|
||||
from .gcp_vision import GCP_VISION_CREDENTIALS
|
||||
from .github import GITHUB_CREDENTIALS
|
||||
from .google_analytics import GOOGLE_ANALYTICS_CREDENTIALS
|
||||
from .google_calendar import GOOGLE_CALENDAR_CREDENTIALS
|
||||
from .google_docs import GOOGLE_DOCS_CREDENTIALS
|
||||
from .google_maps import GOOGLE_MAPS_CREDENTIALS
|
||||
@@ -100,6 +102,7 @@ CREDENTIAL_SPECS = {
|
||||
**APOLLO_CREDENTIALS,
|
||||
**DISCORD_CREDENTIALS,
|
||||
**GITHUB_CREDENTIALS,
|
||||
**GOOGLE_ANALYTICS_CREDENTIALS,
|
||||
**GOOGLE_DOCS_CREDENTIALS,
|
||||
**GOOGLE_MAPS_CREDENTIALS,
|
||||
**HUBSPOT_CREDENTIALS,
|
||||
@@ -145,6 +148,7 @@ __all__ = [
|
||||
"EMAIL_CREDENTIALS",
|
||||
"GCP_VISION_CREDENTIALS",
|
||||
"GITHUB_CREDENTIALS",
|
||||
"GOOGLE_ANALYTICS_CREDENTIALS",
|
||||
"GOOGLE_DOCS_CREDENTIALS",
|
||||
"GOOGLE_MAPS_CREDENTIALS",
|
||||
"HUBSPOT_CREDENTIALS",
|
||||
|
||||
@@ -9,6 +9,7 @@ from .base import CredentialSpec
|
||||
BIGQUERY_CREDENTIALS = {
|
||||
"bigquery": CredentialSpec(
|
||||
env_var="GOOGLE_APPLICATION_CREDENTIALS",
|
||||
credential_group="google_cloud",
|
||||
tools=["run_bigquery_query", "describe_dataset"],
|
||||
required=False, # Falls back to ADC if not set
|
||||
startup_required=False,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""
|
||||
Google Analytics credentials.
|
||||
|
||||
Contains credentials for Google Analytics 4 Data API integration.
|
||||
"""
|
||||
|
||||
from .base import CredentialSpec
|
||||
|
||||
GOOGLE_ANALYTICS_CREDENTIALS = {
|
||||
"google_analytics": CredentialSpec(
|
||||
env_var="GOOGLE_APPLICATION_CREDENTIALS",
|
||||
credential_group="google_cloud",
|
||||
tools=[
|
||||
"ga_run_report",
|
||||
"ga_get_realtime",
|
||||
"ga_get_top_pages",
|
||||
"ga_get_traffic_sources",
|
||||
],
|
||||
required=True,
|
||||
startup_required=False,
|
||||
help_url="https://developers.google.com/analytics/devguides/reporting/data/v1/quickstart-client-libraries",
|
||||
description="Path to Google Cloud service account JSON key with Analytics read access",
|
||||
# Auth method support
|
||||
aden_supported=False,
|
||||
direct_api_key_supported=True,
|
||||
api_key_instructions="""To set up Google Analytics credentials:
|
||||
1. Go to Google Cloud Console > IAM & Admin > Service Accounts
|
||||
2. Create a service account (e.g., "hive-analytics-reader")
|
||||
3. Download the JSON key file
|
||||
4. In Google Analytics, go to Admin > Property > Property Access Management
|
||||
5. Add the service account email with "Viewer" role
|
||||
6. Set the env var to the path of the JSON key file:
|
||||
export GOOGLE_APPLICATION_CREDENTIALS=/path/to/key.json""",
|
||||
# Health check - GA4 Data API doesn't have a simple health endpoint
|
||||
health_check_endpoint="",
|
||||
health_check_method="GET",
|
||||
# Credential store mapping
|
||||
credential_id="google_analytics",
|
||||
credential_key="service_account_key_path",
|
||||
),
|
||||
}
|
||||
@@ -54,6 +54,7 @@ from .file_system_toolkits.view_file import register_tools as register_view_file
|
||||
from .file_system_toolkits.write_to_file import register_tools as register_write_to_file
|
||||
from .github_tool import register_tools as register_github
|
||||
from .gmail_tool import register_tools as register_gmail
|
||||
from .google_analytics_tool import register_tools as register_google_analytics
|
||||
from .google_docs_tool import register_tools as register_google_docs
|
||||
from .google_maps_tool import register_tools as register_google_maps
|
||||
from .http_headers_scanner import register_tools as register_http_headers_scanner
|
||||
@@ -126,6 +127,7 @@ def register_all_tools(
|
||||
register_slack(mcp, credentials=credentials)
|
||||
register_telegram(mcp, credentials=credentials)
|
||||
register_vision(mcp, credentials=credentials)
|
||||
register_google_analytics(mcp, credentials=credentials)
|
||||
register_google_docs(mcp, credentials=credentials)
|
||||
register_google_maps(mcp, credentials=credentials)
|
||||
register_account_info(mcp, credentials=credentials)
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
# Google Analytics Tool
|
||||
|
||||
Query GA4 website traffic and marketing performance data via the Data API v1.
|
||||
|
||||
## Description
|
||||
|
||||
Provides read-only access to Google Analytics 4 (GA4) properties. Use these tools to pull website traffic data, monitor real-time activity, and analyze marketing performance.
|
||||
|
||||
Supports:
|
||||
- **Custom reports** with any combination of GA4 dimensions and metrics
|
||||
- **Real-time data** for current website activity
|
||||
- **Convenience wrappers** for common queries (top pages, traffic sources)
|
||||
|
||||
## Tools
|
||||
|
||||
### `ga_run_report`
|
||||
|
||||
Run a custom GA4 report with flexible dimensions, metrics, and date ranges.
|
||||
|
||||
| Argument | Type | Required | Default | Description |
|
||||
|----------|------|----------|---------|-------------|
|
||||
| `property_id` | str | Yes | - | GA4 property ID (e.g., `"properties/123456"`) |
|
||||
| `metrics` | list[str] | Yes | - | Metrics to retrieve (e.g., `["sessions", "totalUsers"]`) |
|
||||
| `dimensions` | list[str] | No | `None` | Dimensions to group by (e.g., `["pagePath", "sessionSource"]`) |
|
||||
| `start_date` | str | No | `"28daysAgo"` | Start date (e.g., `"2024-01-01"` or `"7daysAgo"`) |
|
||||
| `end_date` | str | No | `"today"` | End date |
|
||||
| `limit` | int | No | `100` | Max rows to return (1-10000) |
|
||||
|
||||
### `ga_get_realtime`
|
||||
|
||||
Get real-time analytics data (active users, current pages).
|
||||
|
||||
| Argument | Type | Required | Default | Description |
|
||||
|----------|------|----------|---------|-------------|
|
||||
| `property_id` | str | Yes | - | GA4 property ID |
|
||||
| `metrics` | list[str] | No | `["activeUsers"]` | Metrics to retrieve |
|
||||
|
||||
### `ga_get_top_pages`
|
||||
|
||||
Get top pages by views and engagement (convenience wrapper).
|
||||
|
||||
| Argument | Type | Required | Default | Description |
|
||||
|----------|------|----------|---------|-------------|
|
||||
| `property_id` | str | Yes | - | GA4 property ID |
|
||||
| `start_date` | str | No | `"28daysAgo"` | Start date |
|
||||
| `end_date` | str | No | `"today"` | End date |
|
||||
| `limit` | int | No | `10` | Max pages to return (1-10000) |
|
||||
|
||||
Returns: `pagePath`, `pageTitle`, `screenPageViews`, `averageSessionDuration`, `bounceRate`
|
||||
|
||||
### `ga_get_traffic_sources`
|
||||
|
||||
Get traffic breakdown by source/medium (convenience wrapper).
|
||||
|
||||
| Argument | Type | Required | Default | Description |
|
||||
|----------|------|----------|---------|-------------|
|
||||
| `property_id` | str | Yes | - | GA4 property ID |
|
||||
| `start_date` | str | No | `"28daysAgo"` | Start date |
|
||||
| `end_date` | str | No | `"today"` | End date |
|
||||
| `limit` | int | No | `10` | Max sources to return (1-10000) |
|
||||
|
||||
Returns: `sessionSource`, `sessionMedium`, `sessions`, `totalUsers`, `conversions`
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `GOOGLE_APPLICATION_CREDENTIALS` | Yes | Path to Google Cloud service account JSON key file |
|
||||
|
||||
## Setup
|
||||
|
||||
1. Go to [Google Cloud Console](https://console.cloud.google.com/) > IAM & Admin > Service Accounts
|
||||
2. Create a service account (e.g., "hive-analytics-reader")
|
||||
3. Download the JSON key file
|
||||
4. Enable the **Google Analytics Data API** in your Google Cloud project
|
||||
5. In Google Analytics, go to Admin > Property > Property Access Management
|
||||
6. Add the service account email with **Viewer** role
|
||||
7. Set the environment variable:
|
||||
```bash
|
||||
export GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account-key.json
|
||||
```
|
||||
|
||||
## Common GA4 Metrics
|
||||
|
||||
`sessions`, `totalUsers`, `newUsers`, `screenPageViews`, `conversions`, `bounceRate`, `averageSessionDuration`, `engagedSessions`
|
||||
|
||||
## Common GA4 Dimensions
|
||||
|
||||
`pagePath`, `pageTitle`, `sessionSource`, `sessionMedium`, `country`, `deviceCategory`, `date`
|
||||
|
||||
## Example Usage
|
||||
|
||||
```python
|
||||
# Custom report: sessions by page over the last 7 days
|
||||
result = ga_run_report(
|
||||
property_id="properties/123456",
|
||||
metrics=["sessions", "screenPageViews"],
|
||||
dimensions=["pagePath"],
|
||||
start_date="7daysAgo",
|
||||
)
|
||||
|
||||
# Real-time active users
|
||||
result = ga_get_realtime(property_id="properties/123456")
|
||||
|
||||
# Top 10 pages this month
|
||||
result = ga_get_top_pages(
|
||||
property_id="properties/123456",
|
||||
start_date="2024-01-01",
|
||||
end_date="2024-01-31",
|
||||
)
|
||||
|
||||
# Traffic sources breakdown
|
||||
result = ga_get_traffic_sources(property_id="properties/123456")
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
Returns error dicts for common issues:
|
||||
- `Google Analytics credentials not configured` - No credentials set
|
||||
- `property_id must start with 'properties/'` - Invalid property ID format
|
||||
- `metrics list must not be empty` - No metrics provided
|
||||
- `limit must be between 1 and 10000` - Limit out of bounds
|
||||
- `Failed to initialize Google Analytics client` - Bad credentials file
|
||||
- `Google Analytics API error: ...` - API-level errors (permissions, quota, etc.)
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Google Analytics Tool - Query GA4 website traffic and marketing data."""
|
||||
|
||||
from .google_analytics_tool import register_tools
|
||||
|
||||
__all__ = ["register_tools"]
|
||||
@@ -0,0 +1,337 @@
|
||||
"""
|
||||
Google Analytics Tool - Query GA4 website traffic and marketing performance data.
|
||||
|
||||
Provides read-only access to Google Analytics 4 via the Data API v1.
|
||||
|
||||
Supports:
|
||||
- Service account authentication (GOOGLE_APPLICATION_CREDENTIALS)
|
||||
- Credential store via CredentialStoreAdapter
|
||||
|
||||
API Reference: https://developers.google.com/analytics/devguides/reporting/data/v1
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from google.analytics.data_v1beta import BetaAnalyticsDataClient
|
||||
from google.analytics.data_v1beta.types import (
|
||||
DateRange,
|
||||
Dimension,
|
||||
Metric,
|
||||
MinuteRange,
|
||||
RunRealtimeReportRequest,
|
||||
RunReportRequest,
|
||||
)
|
||||
from google.oauth2.service_account import Credentials
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from aden_tools.credentials import CredentialStoreAdapter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _GAClient:
|
||||
"""Internal client wrapping Google Analytics 4 Data API v1beta calls."""
|
||||
|
||||
def __init__(self, credentials_path: str):
|
||||
self._credentials_path = credentials_path
|
||||
creds = Credentials.from_service_account_file(credentials_path)
|
||||
self._client = BetaAnalyticsDataClient(credentials=creds)
|
||||
|
||||
def run_report(
|
||||
self,
|
||||
property_id: str,
|
||||
metrics: list[str],
|
||||
dimensions: list[str] | None = None,
|
||||
start_date: str = "28daysAgo",
|
||||
end_date: str = "today",
|
||||
limit: int = 100,
|
||||
) -> dict[str, Any]:
|
||||
"""Run a GA4 report and return structured results."""
|
||||
request = RunReportRequest(
|
||||
property=property_id,
|
||||
metrics=[Metric(name=m) for m in metrics],
|
||||
dimensions=[Dimension(name=d) for d in (dimensions or [])],
|
||||
date_ranges=[DateRange(start_date=start_date, end_date=end_date)],
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
response = self._client.run_report(request)
|
||||
return self._format_report_response(response)
|
||||
|
||||
def run_realtime_report(
|
||||
self,
|
||||
property_id: str,
|
||||
metrics: list[str],
|
||||
) -> dict[str, Any]:
|
||||
"""Run a GA4 realtime report."""
|
||||
request = RunRealtimeReportRequest(
|
||||
property=property_id,
|
||||
metrics=[Metric(name=m) for m in metrics],
|
||||
minute_ranges=[MinuteRange(start_minutes_ago=29, end_minutes_ago=0)],
|
||||
)
|
||||
|
||||
response = self._client.run_realtime_report(request)
|
||||
return self._format_realtime_response(response)
|
||||
|
||||
def _format_report_response(
|
||||
self,
|
||||
response: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Format a RunReportResponse into a plain dict."""
|
||||
rows = []
|
||||
dim_headers = [h.name for h in response.dimension_headers]
|
||||
metric_headers = [h.name for h in response.metric_headers]
|
||||
|
||||
for row in response.rows:
|
||||
row_data: dict[str, str] = {}
|
||||
for i, dim_value in enumerate(row.dimension_values):
|
||||
row_data[dim_headers[i]] = dim_value.value
|
||||
for i, metric_value in enumerate(row.metric_values):
|
||||
row_data[metric_headers[i]] = metric_value.value
|
||||
rows.append(row_data)
|
||||
|
||||
return {
|
||||
"row_count": response.row_count,
|
||||
"rows": rows,
|
||||
"dimension_headers": dim_headers,
|
||||
"metric_headers": metric_headers,
|
||||
}
|
||||
|
||||
def _format_realtime_response(
|
||||
self,
|
||||
response: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Format a RunRealtimeReportResponse into a plain dict."""
|
||||
rows = []
|
||||
metric_headers = [h.name for h in response.metric_headers]
|
||||
|
||||
for row in response.rows:
|
||||
row_data: dict[str, str] = {}
|
||||
for i, metric_value in enumerate(row.metric_values):
|
||||
row_data[metric_headers[i]] = metric_value.value
|
||||
rows.append(row_data)
|
||||
|
||||
return {
|
||||
"row_count": response.row_count,
|
||||
"rows": rows,
|
||||
"metric_headers": metric_headers,
|
||||
}
|
||||
|
||||
|
||||
def register_tools(
|
||||
mcp: FastMCP,
|
||||
credentials: CredentialStoreAdapter | None = None,
|
||||
) -> None:
|
||||
"""Register Google Analytics tools with the MCP server."""
|
||||
|
||||
def _get_credentials_path() -> str | None:
|
||||
"""Get GA credentials path from credential store or environment."""
|
||||
if credentials is not None:
|
||||
path = credentials.get("google_analytics")
|
||||
if path is not None and not isinstance(path, str):
|
||||
raise TypeError(
|
||||
f"Expected string from credentials.get('google_analytics'), "
|
||||
f"got {type(path).__name__}"
|
||||
)
|
||||
return path
|
||||
return os.getenv("GOOGLE_APPLICATION_CREDENTIALS")
|
||||
|
||||
def _get_client() -> _GAClient | dict[str, str]:
|
||||
"""Get a GA client, or return an error dict if no credentials."""
|
||||
creds_path = _get_credentials_path()
|
||||
if not creds_path:
|
||||
return {
|
||||
"error": "Google Analytics credentials not configured",
|
||||
"help": (
|
||||
"Set GOOGLE_APPLICATION_CREDENTIALS environment variable "
|
||||
"to the path of your service account JSON key file, "
|
||||
"or configure via credential store"
|
||||
),
|
||||
}
|
||||
try:
|
||||
return _GAClient(creds_path)
|
||||
except Exception as e:
|
||||
return {"error": f"Failed to initialize Google Analytics client: {e}"}
|
||||
|
||||
def _validate_inputs(property_id: str, *, limit: int | None = None) -> dict[str, str] | None:
|
||||
"""Validate common inputs. Returns an error dict or None."""
|
||||
if not property_id or not property_id.startswith("properties/"):
|
||||
return {
|
||||
"error": "property_id must start with 'properties/' (e.g., 'properties/123456')"
|
||||
}
|
||||
if limit is not None and (limit < 1 or limit > 10000):
|
||||
return {"error": "limit must be between 1 and 10000"}
|
||||
return None
|
||||
|
||||
@mcp.tool()
|
||||
def ga_run_report(
|
||||
property_id: str,
|
||||
metrics: list[str],
|
||||
dimensions: list[str] | None = None,
|
||||
start_date: str = "28daysAgo",
|
||||
end_date: str = "today",
|
||||
limit: int = 100,
|
||||
) -> dict:
|
||||
"""
|
||||
Run a custom Google Analytics 4 report.
|
||||
|
||||
Use this tool to query website traffic data with custom dimensions,
|
||||
metrics, and date ranges.
|
||||
|
||||
Args:
|
||||
property_id: GA4 property ID (e.g., "properties/123456")
|
||||
metrics: Metrics to retrieve
|
||||
(e.g., ["sessions", "totalUsers", "conversions"])
|
||||
dimensions: Dimensions to group by
|
||||
(e.g., ["pagePath", "sessionSource"])
|
||||
start_date: Start date (e.g., "2024-01-01" or "28daysAgo")
|
||||
end_date: End date (e.g., "today")
|
||||
limit: Max rows to return (1-10000)
|
||||
|
||||
Returns:
|
||||
Dict with report rows or error
|
||||
"""
|
||||
client = _get_client()
|
||||
if isinstance(client, dict):
|
||||
return client
|
||||
|
||||
if err := _validate_inputs(property_id, limit=limit):
|
||||
return err
|
||||
if not metrics:
|
||||
return {"error": "metrics list must not be empty"}
|
||||
|
||||
try:
|
||||
return client.run_report(
|
||||
property_id=property_id,
|
||||
metrics=metrics,
|
||||
dimensions=dimensions,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
limit=limit,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("ga_run_report failed: %s", e)
|
||||
return {"error": f"Google Analytics API error: {e}"}
|
||||
|
||||
@mcp.tool()
|
||||
def ga_get_realtime(
|
||||
property_id: str,
|
||||
metrics: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Get real-time Google Analytics data (active users, current pages).
|
||||
|
||||
Use this tool to check current website activity and detect traffic anomalies.
|
||||
|
||||
Args:
|
||||
property_id: GA4 property ID (e.g., "properties/123456")
|
||||
metrics: Metrics to retrieve (default: ["activeUsers"])
|
||||
|
||||
Returns:
|
||||
Dict with real-time data or error
|
||||
"""
|
||||
client = _get_client()
|
||||
if isinstance(client, dict):
|
||||
return client
|
||||
|
||||
if err := _validate_inputs(property_id):
|
||||
return err
|
||||
|
||||
effective_metrics = metrics or ["activeUsers"]
|
||||
|
||||
try:
|
||||
return client.run_realtime_report(
|
||||
property_id=property_id,
|
||||
metrics=effective_metrics,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("ga_get_realtime failed: %s", e)
|
||||
return {"error": f"Google Analytics API error: {e}"}
|
||||
|
||||
@mcp.tool()
|
||||
def ga_get_top_pages(
|
||||
property_id: str,
|
||||
start_date: str = "28daysAgo",
|
||||
end_date: str = "today",
|
||||
limit: int = 10,
|
||||
) -> dict:
|
||||
"""
|
||||
Get top pages by views and engagement.
|
||||
|
||||
Convenience wrapper that returns the most-visited pages with
|
||||
key engagement metrics.
|
||||
|
||||
Args:
|
||||
property_id: GA4 property ID (e.g., "properties/123456")
|
||||
start_date: Start date (e.g., "2024-01-01" or "28daysAgo")
|
||||
end_date: End date (e.g., "today")
|
||||
limit: Max pages to return (1-10000, default 10)
|
||||
|
||||
Returns:
|
||||
Dict with top pages, their views, avg engagement time, and bounce rate
|
||||
"""
|
||||
client = _get_client()
|
||||
if isinstance(client, dict):
|
||||
return client
|
||||
|
||||
if err := _validate_inputs(property_id, limit=limit):
|
||||
return err
|
||||
|
||||
try:
|
||||
return client.run_report(
|
||||
property_id=property_id,
|
||||
metrics=["screenPageViews", "averageSessionDuration", "bounceRate"],
|
||||
dimensions=["pagePath", "pageTitle"],
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
limit=limit,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("ga_get_top_pages failed: %s", e)
|
||||
return {"error": f"Google Analytics API error: {e}"}
|
||||
|
||||
@mcp.tool()
|
||||
def ga_get_traffic_sources(
|
||||
property_id: str,
|
||||
start_date: str = "28daysAgo",
|
||||
end_date: str = "today",
|
||||
limit: int = 10,
|
||||
) -> dict:
|
||||
"""
|
||||
Get traffic breakdown by source/medium.
|
||||
|
||||
Convenience wrapper that shows which channels drive visitors to the site.
|
||||
|
||||
Args:
|
||||
property_id: GA4 property ID (e.g., "properties/123456")
|
||||
start_date: Start date (e.g., "2024-01-01" or "28daysAgo")
|
||||
end_date: End date (e.g., "today")
|
||||
limit: Max sources to return (1-10000, default 10)
|
||||
|
||||
Returns:
|
||||
Dict with traffic sources, sessions, users, and conversions per source
|
||||
"""
|
||||
client = _get_client()
|
||||
if isinstance(client, dict):
|
||||
return client
|
||||
|
||||
if err := _validate_inputs(property_id, limit=limit):
|
||||
return err
|
||||
|
||||
try:
|
||||
return client.run_report(
|
||||
property_id=property_id,
|
||||
metrics=["sessions", "totalUsers", "conversions"],
|
||||
dimensions=["sessionSource", "sessionMedium"],
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
limit=limit,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("ga_get_traffic_sources failed: %s", e)
|
||||
return {"error": f"Google Analytics API error: {e}"}
|
||||
@@ -0,0 +1 @@
|
||||
"""Credential-specific tests."""
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Tests for Google Analytics credential spec."""
|
||||
|
||||
from aden_tools.credentials import CREDENTIAL_SPECS
|
||||
from aden_tools.credentials.google_analytics import GOOGLE_ANALYTICS_CREDENTIALS
|
||||
|
||||
|
||||
class TestGoogleAnalyticsCredentials:
|
||||
"""Tests for the Google Analytics credential specification."""
|
||||
|
||||
def test_credential_spec_exists(self):
|
||||
"""google_analytics spec exists in the module."""
|
||||
assert "google_analytics" in GOOGLE_ANALYTICS_CREDENTIALS
|
||||
|
||||
def test_credential_registered_in_global_specs(self):
|
||||
"""google_analytics spec is merged into CREDENTIAL_SPECS."""
|
||||
assert "google_analytics" in CREDENTIAL_SPECS
|
||||
|
||||
def test_env_var(self):
|
||||
"""Spec points to the correct environment variable."""
|
||||
spec = GOOGLE_ANALYTICS_CREDENTIALS["google_analytics"]
|
||||
assert spec.env_var == "GOOGLE_APPLICATION_CREDENTIALS"
|
||||
|
||||
def test_tools_list(self):
|
||||
"""Spec lists all four GA tool names."""
|
||||
spec = GOOGLE_ANALYTICS_CREDENTIALS["google_analytics"]
|
||||
expected = [
|
||||
"ga_run_report",
|
||||
"ga_get_realtime",
|
||||
"ga_get_top_pages",
|
||||
"ga_get_traffic_sources",
|
||||
]
|
||||
assert spec.tools == expected
|
||||
|
||||
def test_required_flag(self):
|
||||
"""Credential is required."""
|
||||
spec = GOOGLE_ANALYTICS_CREDENTIALS["google_analytics"]
|
||||
assert spec.required is True
|
||||
|
||||
def test_not_startup_required(self):
|
||||
"""Credential is not required at startup."""
|
||||
spec = GOOGLE_ANALYTICS_CREDENTIALS["google_analytics"]
|
||||
assert spec.startup_required is False
|
||||
|
||||
def test_help_url_set(self):
|
||||
"""Help URL points to GA4 quickstart docs."""
|
||||
spec = GOOGLE_ANALYTICS_CREDENTIALS["google_analytics"]
|
||||
assert "developers.google.com" in spec.help_url
|
||||
|
||||
def test_description_set(self):
|
||||
"""Description is non-empty."""
|
||||
spec = GOOGLE_ANALYTICS_CREDENTIALS["google_analytics"]
|
||||
assert spec.description
|
||||
assert "service account" in spec.description.lower()
|
||||
@@ -481,7 +481,14 @@ class TestSpecCompleteness:
|
||||
def test_credential_group_default_empty(self):
|
||||
"""Specs without a group have empty credential_group."""
|
||||
for name, spec in CREDENTIAL_SPECS.items():
|
||||
if name not in ("google_search", "google_cse", "razorpay", "razorpay_secret"):
|
||||
if name not in (
|
||||
"google_search",
|
||||
"google_cse",
|
||||
"razorpay",
|
||||
"razorpay_secret",
|
||||
"google_analytics",
|
||||
"bigquery",
|
||||
):
|
||||
assert spec.credential_group == "", (
|
||||
f"Credential '{name}' has unexpected credential_group='{spec.credential_group}'"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,771 @@
|
||||
"""
|
||||
Tests for Google Analytics tool.
|
||||
|
||||
Covers:
|
||||
- _GAClient methods (run_report, run_realtime_report, response formatting)
|
||||
- Credential retrieval (CredentialStoreAdapter vs env var)
|
||||
- Input validation for all tool functions
|
||||
- Error handling (no credentials, API errors, timeouts)
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from aden_tools.tools.google_analytics_tool.google_analytics_tool import (
|
||||
_GAClient,
|
||||
register_tools,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers to build mock GA4 API responses
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_header(name: str) -> MagicMock:
|
||||
header = MagicMock()
|
||||
header.name = name
|
||||
return header
|
||||
|
||||
|
||||
def _make_value(value: str) -> MagicMock:
|
||||
v = MagicMock()
|
||||
v.value = value
|
||||
return v
|
||||
|
||||
|
||||
def _make_row(dim_values: list[str], metric_values: list[str]) -> MagicMock:
|
||||
row = MagicMock()
|
||||
row.dimension_values = [_make_value(v) for v in dim_values]
|
||||
row.metric_values = [_make_value(v) for v in metric_values]
|
||||
return row
|
||||
|
||||
|
||||
def _make_report_response(
|
||||
dim_headers: list[str],
|
||||
metric_headers: list[str],
|
||||
rows: list[tuple[list[str], list[str]]],
|
||||
row_count: int | None = None,
|
||||
) -> MagicMock:
|
||||
resp = MagicMock()
|
||||
resp.dimension_headers = [_make_header(h) for h in dim_headers]
|
||||
resp.metric_headers = [_make_header(h) for h in metric_headers]
|
||||
resp.rows = [_make_row(dims, metrics) for dims, metrics in rows]
|
||||
resp.row_count = row_count if row_count is not None else len(rows)
|
||||
return resp
|
||||
|
||||
|
||||
def _make_realtime_response(
|
||||
metric_headers: list[str],
|
||||
rows: list[list[str]],
|
||||
row_count: int | None = None,
|
||||
) -> MagicMock:
|
||||
resp = MagicMock()
|
||||
resp.dimension_headers = []
|
||||
resp.metric_headers = [_make_header(h) for h in metric_headers]
|
||||
resp.rows = [_make_row([], metrics) for metrics in rows]
|
||||
resp.row_count = row_count if row_count is not None else len(rows)
|
||||
return resp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _GAClient tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGAClient:
|
||||
"""Tests for the internal _GAClient class."""
|
||||
|
||||
@patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.Credentials")
|
||||
@patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient")
|
||||
def test_format_report_response(self, mock_client_cls, mock_creds):
|
||||
"""Report response is formatted into a plain dict."""
|
||||
client = _GAClient("/fake/path.json")
|
||||
|
||||
response = _make_report_response(
|
||||
dim_headers=["pagePath"],
|
||||
metric_headers=["screenPageViews", "sessions"],
|
||||
rows=[
|
||||
(["/home"], ["1000", "500"]),
|
||||
(["/about"], ["200", "100"]),
|
||||
],
|
||||
)
|
||||
|
||||
result = client._format_report_response(response)
|
||||
|
||||
assert result["row_count"] == 2
|
||||
assert len(result["rows"]) == 2
|
||||
assert result["rows"][0] == {
|
||||
"pagePath": "/home",
|
||||
"screenPageViews": "1000",
|
||||
"sessions": "500",
|
||||
}
|
||||
assert result["dimension_headers"] == ["pagePath"]
|
||||
assert result["metric_headers"] == ["screenPageViews", "sessions"]
|
||||
|
||||
@patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.Credentials")
|
||||
@patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient")
|
||||
def test_format_report_response_no_dimensions(self, mock_client_cls, mock_creds):
|
||||
"""Report with no dimensions still returns valid structure."""
|
||||
client = _GAClient("/fake/path.json")
|
||||
|
||||
response = _make_report_response(
|
||||
dim_headers=[],
|
||||
metric_headers=["totalUsers"],
|
||||
rows=[([], ["5000"])],
|
||||
)
|
||||
|
||||
result = client._format_report_response(response)
|
||||
|
||||
assert result["row_count"] == 1
|
||||
assert result["rows"][0] == {"totalUsers": "5000"}
|
||||
assert result["dimension_headers"] == []
|
||||
|
||||
@patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.Credentials")
|
||||
@patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient")
|
||||
def test_format_realtime_response(self, mock_client_cls, mock_creds):
|
||||
"""Realtime response is formatted correctly."""
|
||||
client = _GAClient("/fake/path.json")
|
||||
|
||||
response = _make_realtime_response(
|
||||
metric_headers=["activeUsers"],
|
||||
rows=[["42"]],
|
||||
)
|
||||
|
||||
result = client._format_realtime_response(response)
|
||||
|
||||
assert result["row_count"] == 1
|
||||
assert result["rows"][0] == {"activeUsers": "42"}
|
||||
assert result["metric_headers"] == ["activeUsers"]
|
||||
|
||||
@patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.Credentials")
|
||||
@patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient")
|
||||
def test_run_report_calls_api(self, mock_client_cls, mock_creds):
|
||||
"""run_report sends correct request to GA4 API."""
|
||||
mock_api = MagicMock()
|
||||
mock_client_cls.return_value = mock_api
|
||||
mock_api.run_report.return_value = _make_report_response(
|
||||
dim_headers=["pagePath"],
|
||||
metric_headers=["sessions"],
|
||||
rows=[(["/home"], ["100"])],
|
||||
)
|
||||
|
||||
client = _GAClient("/fake/path.json")
|
||||
result = client.run_report(
|
||||
property_id="properties/123",
|
||||
metrics=["sessions"],
|
||||
dimensions=["pagePath"],
|
||||
start_date="7daysAgo",
|
||||
end_date="today",
|
||||
limit=50,
|
||||
)
|
||||
|
||||
mock_api.run_report.assert_called_once()
|
||||
assert result["row_count"] == 1
|
||||
|
||||
@patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.Credentials")
|
||||
@patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient")
|
||||
def test_run_realtime_report_calls_api(self, mock_client_cls, mock_creds):
|
||||
"""run_realtime_report sends correct request to GA4 API."""
|
||||
mock_api = MagicMock()
|
||||
mock_client_cls.return_value = mock_api
|
||||
mock_api.run_realtime_report.return_value = _make_realtime_response(
|
||||
metric_headers=["activeUsers"],
|
||||
rows=[["10"]],
|
||||
)
|
||||
|
||||
client = _GAClient("/fake/path.json")
|
||||
result = client.run_realtime_report(
|
||||
property_id="properties/123",
|
||||
metrics=["activeUsers"],
|
||||
)
|
||||
|
||||
mock_api.run_realtime_report.assert_called_once()
|
||||
assert result["rows"][0]["activeUsers"] == "10"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Credential retrieval tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCredentialRetrieval:
|
||||
"""Tests for credential resolution in register_tools."""
|
||||
|
||||
def test_no_credentials_returns_error(self, monkeypatch):
|
||||
"""No credentials configured returns helpful error from tool call."""
|
||||
monkeypatch.delenv("GOOGLE_APPLICATION_CREDENTIALS", raising=False)
|
||||
mcp = MagicMock()
|
||||
registered_fns = {}
|
||||
mcp.tool.return_value = lambda fn: registered_fns.update({fn.__name__: fn}) or fn
|
||||
|
||||
register_tools(mcp, credentials=None)
|
||||
|
||||
result = registered_fns["ga_run_report"](
|
||||
property_id="properties/123",
|
||||
metrics=["sessions"],
|
||||
)
|
||||
assert "error" in result
|
||||
assert "not configured" in result["error"]
|
||||
|
||||
def test_credentials_from_env(self, monkeypatch):
|
||||
"""Credentials resolved from environment variable."""
|
||||
monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/path/to/key.json")
|
||||
mcp = MagicMock()
|
||||
registered_fns = {}
|
||||
mcp.tool.return_value = lambda fn: registered_fns.update({fn.__name__: fn}) or fn
|
||||
|
||||
register_tools(mcp, credentials=None)
|
||||
assert "ga_run_report" in registered_fns
|
||||
|
||||
def test_credentials_from_credential_store(self):
|
||||
"""Credentials resolved from CredentialStoreAdapter."""
|
||||
mcp = MagicMock()
|
||||
registered_fns = {}
|
||||
mcp.tool.return_value = lambda fn: registered_fns.update({fn.__name__: fn}) or fn
|
||||
|
||||
cred_manager = MagicMock()
|
||||
cred_manager.get.return_value = "/path/to/key.json"
|
||||
|
||||
register_tools(mcp, credentials=cred_manager)
|
||||
assert "ga_run_report" in registered_fns
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ga_run_report tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGaRunReport:
|
||||
"""Tests for ga_run_report tool function."""
|
||||
|
||||
@pytest.fixture
|
||||
def ga_tools(self, monkeypatch):
|
||||
"""Register GA tools without credentials."""
|
||||
monkeypatch.delenv("GOOGLE_APPLICATION_CREDENTIALS", raising=False)
|
||||
mcp = MagicMock()
|
||||
fns = {}
|
||||
mcp.tool.return_value = lambda fn: fns.update({fn.__name__: fn}) or fn
|
||||
register_tools(mcp, credentials=None)
|
||||
return fns
|
||||
|
||||
@pytest.fixture
|
||||
def ga_tools_with_creds(self, monkeypatch):
|
||||
"""Register GA tools with credentials set (for input validation tests)."""
|
||||
monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/fake/path.json")
|
||||
with (
|
||||
patch(
|
||||
"aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient"
|
||||
),
|
||||
patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.Credentials"),
|
||||
):
|
||||
mcp = MagicMock()
|
||||
fns = {}
|
||||
mcp.tool.return_value = lambda fn: fns.update({fn.__name__: fn}) or fn
|
||||
register_tools(mcp, credentials=None)
|
||||
yield fns
|
||||
|
||||
def test_empty_metrics_returns_error(self, ga_tools_with_creds):
|
||||
"""Empty metrics list returns validation error."""
|
||||
result = ga_tools_with_creds["ga_run_report"](
|
||||
property_id="properties/123",
|
||||
metrics=[],
|
||||
)
|
||||
assert "error" in result
|
||||
assert "metrics" in result["error"].lower()
|
||||
|
||||
def test_invalid_property_id_returns_error(self, ga_tools_with_creds):
|
||||
"""Property ID without 'properties/' prefix returns error."""
|
||||
result = ga_tools_with_creds["ga_run_report"](
|
||||
property_id="123456",
|
||||
metrics=["sessions"],
|
||||
)
|
||||
assert "error" in result
|
||||
assert "properties/" in result["error"]
|
||||
|
||||
def test_empty_property_id_returns_error(self, ga_tools_with_creds):
|
||||
"""Empty property ID returns error."""
|
||||
result = ga_tools_with_creds["ga_run_report"](
|
||||
property_id="",
|
||||
metrics=["sessions"],
|
||||
)
|
||||
assert "error" in result
|
||||
|
||||
def test_limit_too_low_returns_error(self, ga_tools_with_creds):
|
||||
"""Limit of 0 returns error."""
|
||||
result = ga_tools_with_creds["ga_run_report"](
|
||||
property_id="properties/123",
|
||||
metrics=["sessions"],
|
||||
limit=0,
|
||||
)
|
||||
assert "error" in result
|
||||
assert "limit" in result["error"].lower()
|
||||
|
||||
def test_limit_too_high_returns_error(self, ga_tools_with_creds):
|
||||
"""Limit above 10000 returns error."""
|
||||
result = ga_tools_with_creds["ga_run_report"](
|
||||
property_id="properties/123",
|
||||
metrics=["sessions"],
|
||||
limit=10001,
|
||||
)
|
||||
assert "error" in result
|
||||
assert "limit" in result["error"].lower()
|
||||
|
||||
def test_no_credentials_returns_error(self, ga_tools):
|
||||
"""No credentials returns error with help message."""
|
||||
result = ga_tools["ga_run_report"](
|
||||
property_id="properties/123",
|
||||
metrics=["sessions"],
|
||||
)
|
||||
assert "error" in result
|
||||
assert "not configured" in result["error"]
|
||||
assert "help" in result
|
||||
|
||||
@patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.Credentials")
|
||||
@patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient")
|
||||
def test_successful_report(self, mock_client_cls, mock_creds, monkeypatch):
|
||||
"""Successful report returns formatted data."""
|
||||
monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/fake/path.json")
|
||||
|
||||
mock_api = MagicMock()
|
||||
mock_client_cls.return_value = mock_api
|
||||
mock_api.run_report.return_value = _make_report_response(
|
||||
dim_headers=["pagePath"],
|
||||
metric_headers=["sessions"],
|
||||
rows=[(["/home"], ["500"])],
|
||||
)
|
||||
|
||||
mcp = MagicMock()
|
||||
fns = {}
|
||||
mcp.tool.return_value = lambda fn: fns.update({fn.__name__: fn}) or fn
|
||||
register_tools(mcp, credentials=None)
|
||||
|
||||
result = fns["ga_run_report"](
|
||||
property_id="properties/123",
|
||||
metrics=["sessions"],
|
||||
dimensions=["pagePath"],
|
||||
)
|
||||
|
||||
assert result["row_count"] == 1
|
||||
assert result["rows"][0]["pagePath"] == "/home"
|
||||
assert result["rows"][0]["sessions"] == "500"
|
||||
|
||||
@patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.Credentials")
|
||||
@patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient")
|
||||
def test_api_error_returns_error_dict(self, mock_client_cls, mock_creds, monkeypatch):
|
||||
"""API exception is caught and returned as error dict."""
|
||||
monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/fake/path.json")
|
||||
|
||||
mock_api = MagicMock()
|
||||
mock_client_cls.return_value = mock_api
|
||||
mock_api.run_report.side_effect = Exception("Permission denied")
|
||||
|
||||
mcp = MagicMock()
|
||||
fns = {}
|
||||
mcp.tool.return_value = lambda fn: fns.update({fn.__name__: fn}) or fn
|
||||
register_tools(mcp, credentials=None)
|
||||
|
||||
result = fns["ga_run_report"](
|
||||
property_id="properties/123",
|
||||
metrics=["sessions"],
|
||||
)
|
||||
|
||||
assert "error" in result
|
||||
assert "Permission denied" in result["error"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ga_get_realtime tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGaGetRealtime:
|
||||
"""Tests for ga_get_realtime tool function."""
|
||||
|
||||
@pytest.fixture
|
||||
def ga_tools(self, monkeypatch):
|
||||
"""Register GA tools without credentials."""
|
||||
monkeypatch.delenv("GOOGLE_APPLICATION_CREDENTIALS", raising=False)
|
||||
mcp = MagicMock()
|
||||
fns = {}
|
||||
mcp.tool.return_value = lambda fn: fns.update({fn.__name__: fn}) or fn
|
||||
register_tools(mcp, credentials=None)
|
||||
return fns
|
||||
|
||||
@pytest.fixture
|
||||
def ga_tools_with_creds(self, monkeypatch):
|
||||
"""Register GA tools with credentials set (for input validation tests)."""
|
||||
monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/fake/path.json")
|
||||
with (
|
||||
patch(
|
||||
"aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient"
|
||||
),
|
||||
patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.Credentials"),
|
||||
):
|
||||
mcp = MagicMock()
|
||||
fns = {}
|
||||
mcp.tool.return_value = lambda fn: fns.update({fn.__name__: fn}) or fn
|
||||
register_tools(mcp, credentials=None)
|
||||
yield fns
|
||||
|
||||
def test_invalid_property_id_returns_error(self, ga_tools_with_creds):
|
||||
"""Property ID without 'properties/' prefix returns error."""
|
||||
result = ga_tools_with_creds["ga_get_realtime"](property_id="123456")
|
||||
assert "error" in result
|
||||
assert "properties/" in result["error"]
|
||||
|
||||
def test_no_credentials_returns_error(self, ga_tools):
|
||||
"""No credentials returns error."""
|
||||
result = ga_tools["ga_get_realtime"](property_id="properties/123")
|
||||
assert "error" in result
|
||||
assert "not configured" in result["error"]
|
||||
|
||||
def test_default_metrics(self, ga_tools):
|
||||
"""Default metrics is ['activeUsers'] when none provided."""
|
||||
# We can't easily test the default without mocking, but we can
|
||||
# verify it doesn't crash with None metrics
|
||||
result = ga_tools["ga_get_realtime"](property_id="properties/123", metrics=None)
|
||||
assert "error" in result # No credentials, but no crash
|
||||
|
||||
@patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.Credentials")
|
||||
@patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient")
|
||||
def test_successful_realtime(self, mock_client_cls, mock_creds, monkeypatch):
|
||||
"""Successful realtime report returns formatted data."""
|
||||
monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/fake/path.json")
|
||||
|
||||
mock_api = MagicMock()
|
||||
mock_client_cls.return_value = mock_api
|
||||
mock_api.run_realtime_report.return_value = _make_realtime_response(
|
||||
metric_headers=["activeUsers"],
|
||||
rows=[["42"]],
|
||||
)
|
||||
|
||||
mcp = MagicMock()
|
||||
fns = {}
|
||||
mcp.tool.return_value = lambda fn: fns.update({fn.__name__: fn}) or fn
|
||||
register_tools(mcp, credentials=None)
|
||||
|
||||
result = fns["ga_get_realtime"](property_id="properties/123")
|
||||
|
||||
assert result["row_count"] == 1
|
||||
assert result["rows"][0]["activeUsers"] == "42"
|
||||
|
||||
@patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.Credentials")
|
||||
@patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient")
|
||||
def test_custom_metrics(self, mock_client_cls, mock_creds, monkeypatch):
|
||||
"""Custom metrics are passed through to the API."""
|
||||
monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/fake/path.json")
|
||||
|
||||
mock_api = MagicMock()
|
||||
mock_client_cls.return_value = mock_api
|
||||
mock_api.run_realtime_report.return_value = _make_realtime_response(
|
||||
metric_headers=["activeUsers", "screenPageViews"],
|
||||
rows=[["10", "25"]],
|
||||
)
|
||||
|
||||
mcp = MagicMock()
|
||||
fns = {}
|
||||
mcp.tool.return_value = lambda fn: fns.update({fn.__name__: fn}) or fn
|
||||
register_tools(mcp, credentials=None)
|
||||
|
||||
result = fns["ga_get_realtime"](
|
||||
property_id="properties/123",
|
||||
metrics=["activeUsers", "screenPageViews"],
|
||||
)
|
||||
|
||||
assert result["rows"][0]["activeUsers"] == "10"
|
||||
assert result["rows"][0]["screenPageViews"] == "25"
|
||||
|
||||
@patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.Credentials")
|
||||
@patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient")
|
||||
def test_api_error_returns_error_dict(self, mock_client_cls, mock_creds, monkeypatch):
|
||||
"""API exception is caught and returned as error dict."""
|
||||
monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/fake/path.json")
|
||||
|
||||
mock_api = MagicMock()
|
||||
mock_client_cls.return_value = mock_api
|
||||
mock_api.run_realtime_report.side_effect = Exception("Quota exceeded")
|
||||
|
||||
mcp = MagicMock()
|
||||
fns = {}
|
||||
mcp.tool.return_value = lambda fn: fns.update({fn.__name__: fn}) or fn
|
||||
register_tools(mcp, credentials=None)
|
||||
|
||||
result = fns["ga_get_realtime"](property_id="properties/123")
|
||||
|
||||
assert "error" in result
|
||||
assert "Quota exceeded" in result["error"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ga_get_top_pages tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGaGetTopPages:
|
||||
"""Tests for ga_get_top_pages convenience wrapper."""
|
||||
|
||||
@pytest.fixture
|
||||
def ga_tools(self, monkeypatch):
|
||||
"""Register GA tools without credentials."""
|
||||
monkeypatch.delenv("GOOGLE_APPLICATION_CREDENTIALS", raising=False)
|
||||
mcp = MagicMock()
|
||||
fns = {}
|
||||
mcp.tool.return_value = lambda fn: fns.update({fn.__name__: fn}) or fn
|
||||
register_tools(mcp, credentials=None)
|
||||
return fns
|
||||
|
||||
@pytest.fixture
|
||||
def ga_tools_with_creds(self, monkeypatch):
|
||||
"""Register GA tools with credentials set (for input validation tests)."""
|
||||
monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/fake/path.json")
|
||||
with (
|
||||
patch(
|
||||
"aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient"
|
||||
),
|
||||
patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.Credentials"),
|
||||
):
|
||||
mcp = MagicMock()
|
||||
fns = {}
|
||||
mcp.tool.return_value = lambda fn: fns.update({fn.__name__: fn}) or fn
|
||||
register_tools(mcp, credentials=None)
|
||||
yield fns
|
||||
|
||||
def test_invalid_property_id_returns_error(self, ga_tools_with_creds):
|
||||
"""Property ID validation works."""
|
||||
result = ga_tools_with_creds["ga_get_top_pages"](property_id="bad-id")
|
||||
assert "error" in result
|
||||
assert "properties/" in result["error"]
|
||||
|
||||
def test_limit_validation(self, ga_tools_with_creds):
|
||||
"""Limit bounds are checked."""
|
||||
result = ga_tools_with_creds["ga_get_top_pages"](property_id="properties/123", limit=0)
|
||||
assert "error" in result
|
||||
assert "limit" in result["error"].lower()
|
||||
|
||||
def test_no_credentials_returns_error(self, ga_tools):
|
||||
"""No credentials returns error."""
|
||||
result = ga_tools["ga_get_top_pages"](property_id="properties/123")
|
||||
assert "error" in result
|
||||
assert "not configured" in result["error"]
|
||||
|
||||
@patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.Credentials")
|
||||
@patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient")
|
||||
def test_correct_dimensions_and_metrics(self, mock_client_cls, mock_creds, monkeypatch):
|
||||
"""Sends pagePath, pageTitle dimensions and page-related metrics."""
|
||||
monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/fake/path.json")
|
||||
|
||||
mock_api = MagicMock()
|
||||
mock_client_cls.return_value = mock_api
|
||||
mock_api.run_report.return_value = _make_report_response(
|
||||
dim_headers=["pagePath", "pageTitle"],
|
||||
metric_headers=["screenPageViews", "averageSessionDuration", "bounceRate"],
|
||||
rows=[(["/home", "Home Page"], ["1000", "120.5", "0.45"])],
|
||||
)
|
||||
|
||||
mcp = MagicMock()
|
||||
fns = {}
|
||||
mcp.tool.return_value = lambda fn: fns.update({fn.__name__: fn}) or fn
|
||||
register_tools(mcp, credentials=None)
|
||||
|
||||
result = fns["ga_get_top_pages"](property_id="properties/123")
|
||||
|
||||
assert result["row_count"] == 1
|
||||
assert result["rows"][0]["pagePath"] == "/home"
|
||||
assert result["rows"][0]["pageTitle"] == "Home Page"
|
||||
assert result["dimension_headers"] == ["pagePath", "pageTitle"]
|
||||
assert "screenPageViews" in result["metric_headers"]
|
||||
assert "averageSessionDuration" in result["metric_headers"]
|
||||
assert "bounceRate" in result["metric_headers"]
|
||||
|
||||
@patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.Credentials")
|
||||
@patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient")
|
||||
def test_date_range_and_limit_forwarded(self, mock_client_cls, mock_creds, monkeypatch):
|
||||
"""Custom date range and limit are passed to the API."""
|
||||
monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/fake/path.json")
|
||||
|
||||
mock_api = MagicMock()
|
||||
mock_client_cls.return_value = mock_api
|
||||
mock_api.run_report.return_value = _make_report_response(
|
||||
dim_headers=["pagePath", "pageTitle"],
|
||||
metric_headers=["screenPageViews", "averageSessionDuration", "bounceRate"],
|
||||
rows=[],
|
||||
)
|
||||
|
||||
mcp = MagicMock()
|
||||
fns = {}
|
||||
mcp.tool.return_value = lambda fn: fns.update({fn.__name__: fn}) or fn
|
||||
register_tools(mcp, credentials=None)
|
||||
|
||||
fns["ga_get_top_pages"](
|
||||
property_id="properties/123",
|
||||
start_date="2024-01-01",
|
||||
end_date="2024-01-31",
|
||||
limit=5,
|
||||
)
|
||||
|
||||
# Verify the API was called (the request object is constructed internally)
|
||||
mock_api.run_report.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ga_get_traffic_sources tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGaGetTrafficSources:
|
||||
"""Tests for ga_get_traffic_sources convenience wrapper."""
|
||||
|
||||
@pytest.fixture
|
||||
def ga_tools(self, monkeypatch):
|
||||
"""Register GA tools without credentials."""
|
||||
monkeypatch.delenv("GOOGLE_APPLICATION_CREDENTIALS", raising=False)
|
||||
mcp = MagicMock()
|
||||
fns = {}
|
||||
mcp.tool.return_value = lambda fn: fns.update({fn.__name__: fn}) or fn
|
||||
register_tools(mcp, credentials=None)
|
||||
return fns
|
||||
|
||||
@pytest.fixture
|
||||
def ga_tools_with_creds(self, monkeypatch):
|
||||
"""Register GA tools with credentials set (for input validation tests)."""
|
||||
monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/fake/path.json")
|
||||
with (
|
||||
patch(
|
||||
"aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient"
|
||||
),
|
||||
patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.Credentials"),
|
||||
):
|
||||
mcp = MagicMock()
|
||||
fns = {}
|
||||
mcp.tool.return_value = lambda fn: fns.update({fn.__name__: fn}) or fn
|
||||
register_tools(mcp, credentials=None)
|
||||
yield fns
|
||||
|
||||
def test_invalid_property_id_returns_error(self, ga_tools_with_creds):
|
||||
"""Property ID validation works."""
|
||||
result = ga_tools_with_creds["ga_get_traffic_sources"](property_id="bad-id")
|
||||
assert "error" in result
|
||||
assert "properties/" in result["error"]
|
||||
|
||||
def test_limit_validation(self, ga_tools_with_creds):
|
||||
"""Limit bounds are checked."""
|
||||
result = ga_tools_with_creds["ga_get_traffic_sources"](
|
||||
property_id="properties/123", limit=10001
|
||||
)
|
||||
assert "error" in result
|
||||
assert "limit" in result["error"].lower()
|
||||
|
||||
def test_no_credentials_returns_error(self, ga_tools):
|
||||
"""No credentials returns error."""
|
||||
result = ga_tools["ga_get_traffic_sources"](property_id="properties/123")
|
||||
assert "error" in result
|
||||
assert "not configured" in result["error"]
|
||||
|
||||
@patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.Credentials")
|
||||
@patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient")
|
||||
def test_correct_dimensions_and_metrics(self, mock_client_cls, mock_creds, monkeypatch):
|
||||
"""Sends sessionSource, sessionMedium dimensions and traffic metrics."""
|
||||
monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/fake/path.json")
|
||||
|
||||
mock_api = MagicMock()
|
||||
mock_client_cls.return_value = mock_api
|
||||
mock_api.run_report.return_value = _make_report_response(
|
||||
dim_headers=["sessionSource", "sessionMedium"],
|
||||
metric_headers=["sessions", "totalUsers", "conversions"],
|
||||
rows=[
|
||||
(["google", "organic"], ["500", "400", "10"]),
|
||||
(["direct", "(none)"], ["200", "180", "5"]),
|
||||
],
|
||||
)
|
||||
|
||||
mcp = MagicMock()
|
||||
fns = {}
|
||||
mcp.tool.return_value = lambda fn: fns.update({fn.__name__: fn}) or fn
|
||||
register_tools(mcp, credentials=None)
|
||||
|
||||
result = fns["ga_get_traffic_sources"](property_id="properties/123")
|
||||
|
||||
assert result["row_count"] == 2
|
||||
assert result["rows"][0]["sessionSource"] == "google"
|
||||
assert result["rows"][0]["sessionMedium"] == "organic"
|
||||
assert result["dimension_headers"] == ["sessionSource", "sessionMedium"]
|
||||
assert "sessions" in result["metric_headers"]
|
||||
assert "totalUsers" in result["metric_headers"]
|
||||
assert "conversions" in result["metric_headers"]
|
||||
|
||||
@patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.Credentials")
|
||||
@patch("aden_tools.tools.google_analytics_tool.google_analytics_tool.BetaAnalyticsDataClient")
|
||||
def test_api_error_returns_error_dict(self, mock_client_cls, mock_creds, monkeypatch):
|
||||
"""API exception is caught and returned as error dict."""
|
||||
monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/fake/path.json")
|
||||
|
||||
mock_api = MagicMock()
|
||||
mock_client_cls.return_value = mock_api
|
||||
mock_api.run_report.side_effect = Exception("Service unavailable")
|
||||
|
||||
mcp = MagicMock()
|
||||
fns = {}
|
||||
mcp.tool.return_value = lambda fn: fns.update({fn.__name__: fn}) or fn
|
||||
register_tools(mcp, credentials=None)
|
||||
|
||||
result = fns["ga_get_traffic_sources"](property_id="properties/123")
|
||||
|
||||
assert "error" in result
|
||||
assert "Service unavailable" in result["error"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool registration tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestToolRegistration:
|
||||
"""Tests for tool registration in register_all_tools."""
|
||||
|
||||
def test_register_tools_registers_all_four_tools(self):
|
||||
"""register_tools registers exactly 4 GA tool functions."""
|
||||
mcp = MagicMock()
|
||||
registered_fns = {}
|
||||
mcp.tool.return_value = lambda fn: registered_fns.update({fn.__name__: fn}) or fn
|
||||
|
||||
register_tools(mcp, credentials=None)
|
||||
|
||||
expected_tools = {
|
||||
"ga_run_report",
|
||||
"ga_get_realtime",
|
||||
"ga_get_top_pages",
|
||||
"ga_get_traffic_sources",
|
||||
}
|
||||
assert set(registered_fns.keys()) == expected_tools
|
||||
|
||||
def test_register_all_tools_includes_ga_tools(self):
|
||||
"""register_all_tools return list includes all GA tool names."""
|
||||
from fastmcp import FastMCP
|
||||
|
||||
from aden_tools.tools import register_all_tools
|
||||
|
||||
mcp = FastMCP("test-ga-registration")
|
||||
|
||||
result = register_all_tools(mcp, credentials=None)
|
||||
|
||||
for tool_name in [
|
||||
"ga_run_report",
|
||||
"ga_get_realtime",
|
||||
"ga_get_top_pages",
|
||||
"ga_get_traffic_sources",
|
||||
]:
|
||||
assert tool_name in result, f"{tool_name} missing from register_all_tools"
|
||||
|
||||
def test_credentials_passed_through(self):
|
||||
"""Credential store adapter is passed to register_tools."""
|
||||
mcp = MagicMock()
|
||||
registered_fns = {}
|
||||
mcp.tool.return_value = lambda fn: registered_fns.update({fn.__name__: fn}) or fn
|
||||
|
||||
cred_manager = MagicMock()
|
||||
cred_manager.get.return_value = "/fake/path.json"
|
||||
|
||||
register_tools(mcp, credentials=cred_manager)
|
||||
|
||||
assert len(registered_fns) == 4
|
||||
@@ -963,6 +963,22 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/01/c9/97cc5aae1648dcb851958a3ddf73ccd7dbe5650d95203ecb4d7720b4cdbf/fsspec-2026.1.0-py3-none-any.whl", hash = "sha256:cb76aa913c2285a3b49bdd5fc55b1d7c708d7208126b60f2eb8194fe1b4cbdcc", size = 201838, upload-time = "2026-01-09T15:21:34.041Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-analytics-data"
|
||||
version = "0.20.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "google-api-core", extra = ["grpc"] },
|
||||
{ name = "google-auth" },
|
||||
{ name = "grpcio" },
|
||||
{ name = "proto-plus" },
|
||||
{ name = "protobuf" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/29/ec/a3ace1e5c6308f79810d24bfa29971bc1d57e3dd114409a5b58619dcaebb/google_analytics_data-0.20.0.tar.gz", hash = "sha256:00b26c813d3153b2f0e0229f3080f10079313cf922dc286c3114a3b33ad51190", size = 225381, upload-time = "2026-01-15T13:14:56.834Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/52/9d/84f043f125dc3b8285ed5e68b983bc6fa90abb22129847f98e51fff720a4/google_analytics_data-0.20.0-py3-none-any.whl", hash = "sha256:2b2e7fd1e801897073b8501127c7fb5f12df69fc2c0fb46c264813a51292ea78", size = 192755, upload-time = "2026-01-09T14:52:34.837Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-api-core"
|
||||
version = "2.29.0"
|
||||
@@ -3477,6 +3493,7 @@ dependencies = [
|
||||
{ name = "dnspython" },
|
||||
{ name = "fastmcp" },
|
||||
{ name = "framework" },
|
||||
{ name = "google-analytics-data" },
|
||||
{ name = "httpx" },
|
||||
{ name = "jsonpath-ng" },
|
||||
{ name = "litellm" },
|
||||
@@ -3539,6 +3556,7 @@ requires-dist = [
|
||||
{ name = "duckdb", marker = "extra == 'sql'", specifier = ">=1.0.0" },
|
||||
{ name = "fastmcp", specifier = ">=2.0.0" },
|
||||
{ name = "framework", editable = "core" },
|
||||
{ name = "google-analytics-data", specifier = ">=0.18.0" },
|
||||
{ name = "google-cloud-bigquery", marker = "extra == 'all'", specifier = ">=3.0.0" },
|
||||
{ name = "google-cloud-bigquery", marker = "extra == 'bigquery'", specifier = ">=3.0.0" },
|
||||
{ name = "httpx", specifier = ">=0.27.0" },
|
||||
|
||||
Reference in New Issue
Block a user