Server start events (#635)
1. Refactored the core usage logic from `arcade_cli` to `arcade_core` 2. Add "MCP server started" event As always, opt out by setting `ARCADE_USAGE_TRACKING` to 0.
This commit is contained in:
parent
66a126bba5
commit
49e53d2b33
19 changed files with 236 additions and 73 deletions
|
|
@ -5,11 +5,13 @@ from typing import Any
|
||||||
from urllib.parse import parse_qs
|
from urllib.parse import parse_qs
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
from arcade_core.constants import (
|
||||||
|
ARCADE_CONFIG_PATH,
|
||||||
|
CREDENTIALS_FILE_PATH,
|
||||||
|
)
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
|
|
||||||
from arcade_cli.constants import (
|
from arcade_cli.constants import (
|
||||||
ARCADE_CONFIG_PATH,
|
|
||||||
CREDENTIALS_FILE_PATH,
|
|
||||||
LOGIN_FAILED_HTML,
|
LOGIN_FAILED_HTML,
|
||||||
LOGIN_SUCCESS_HTML,
|
LOGIN_SUCCESS_HTML,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,8 @@
|
||||||
import os
|
|
||||||
|
|
||||||
PROD_CLOUD_HOST = "cloud.arcade.dev"
|
PROD_CLOUD_HOST = "cloud.arcade.dev"
|
||||||
PROD_ENGINE_HOST = "api.arcade.dev"
|
PROD_ENGINE_HOST = "api.arcade.dev"
|
||||||
LOCALHOST = "localhost"
|
LOCALHOST = "localhost"
|
||||||
LOCAL_AUTH_CALLBACK_PORT = 9905
|
LOCAL_AUTH_CALLBACK_PORT = 9905
|
||||||
|
|
||||||
# The path to the directory containing the Arcade configuration files. Typically ~/.arcade
|
|
||||||
ARCADE_CONFIG_PATH = os.path.join(os.path.expanduser(os.getenv("ARCADE_WORK_DIR", "~")), ".arcade")
|
|
||||||
|
|
||||||
# The path to the file containing the user's Arcade-related credentials (e.g., ARCADE_API_KEY).
|
|
||||||
CREDENTIALS_FILE_PATH = os.path.join(ARCADE_CONFIG_PATH, "credentials.yaml")
|
|
||||||
|
|
||||||
# The path to the file containing usage analytics identity data.
|
|
||||||
USAGE_FILE_PATH = os.path.join(ARCADE_CONFIG_PATH, "usage.json")
|
|
||||||
|
|
||||||
_style_block = b"""
|
_style_block = b"""
|
||||||
<link rel="icon" href="https://cdn.arcade.dev/favicons/favicon.ico" sizes="any">
|
<link rel="icon" href="https://cdn.arcade.dev/favicons/favicon.ico" sizes="any">
|
||||||
<link rel="apple-touch-icon" href="https://cdn.arcade.dev/favicons/apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="https://cdn.arcade.dev/favicons/apple-touch-icon.png">
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ from typing import Optional
|
||||||
|
|
||||||
import click
|
import click
|
||||||
import typer
|
import typer
|
||||||
|
from arcade_core.constants import CREDENTIALS_FILE_PATH
|
||||||
from arcadepy import Arcade
|
from arcadepy import Arcade
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
|
|
@ -19,7 +20,6 @@ import arcade_cli.secret as secret
|
||||||
import arcade_cli.worker as worker
|
import arcade_cli.worker as worker
|
||||||
from arcade_cli.authn import LocalAuthCallbackServer, check_existing_login
|
from arcade_cli.authn import LocalAuthCallbackServer, check_existing_login
|
||||||
from arcade_cli.constants import (
|
from arcade_cli.constants import (
|
||||||
CREDENTIALS_FILE_PATH,
|
|
||||||
PROD_CLOUD_HOST,
|
PROD_CLOUD_HOST,
|
||||||
PROD_ENGINE_HOST,
|
PROD_ENGINE_HOST,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,15 @@ from importlib import metadata
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
from arcade_cli.constants import ARCADE_CONFIG_PATH
|
|
||||||
from arcade_cli.usage.constants import (
|
from arcade_cli.usage.constants import (
|
||||||
EVENT_CLI_COMMAND_EXECUTED,
|
EVENT_CLI_COMMAND_EXECUTED,
|
||||||
EVENT_CLI_COMMAND_FAILED,
|
EVENT_CLI_COMMAND_FAILED,
|
||||||
PROP_CLI_VERSION,
|
PROP_CLI_VERSION,
|
||||||
PROP_COMMAND_NAME,
|
PROP_COMMAND_NAME,
|
||||||
|
)
|
||||||
|
from arcade_core.constants import ARCADE_CONFIG_PATH
|
||||||
|
from arcade_core.usage import UsageIdentity, UsageService, is_tracking_enabled
|
||||||
|
from arcade_core.usage.constants import (
|
||||||
PROP_DEVICE_MONOTONIC_END,
|
PROP_DEVICE_MONOTONIC_END,
|
||||||
PROP_DEVICE_MONOTONIC_START,
|
PROP_DEVICE_MONOTONIC_START,
|
||||||
PROP_DURATION_MS,
|
PROP_DURATION_MS,
|
||||||
|
|
@ -22,9 +25,6 @@ from arcade_cli.usage.constants import (
|
||||||
PROP_RUNTIME_LANGUAGE,
|
PROP_RUNTIME_LANGUAGE,
|
||||||
PROP_RUNTIME_VERSION,
|
PROP_RUNTIME_VERSION,
|
||||||
)
|
)
|
||||||
from arcade_cli.usage.identity import UsageIdentity
|
|
||||||
from arcade_cli.usage.usage_service import UsageService
|
|
||||||
from arcade_cli.usage.utils import is_tracking_enabled
|
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from typer.core import TyperCommand, TyperGroup
|
from typer.core import TyperCommand, TyperGroup
|
||||||
from typer.models import Context
|
from typer.models import Context
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,7 @@
|
||||||
"""Constants for usage tracking and analytics."""
|
# CLI Specific Event Names
|
||||||
|
|
||||||
# Event Names
|
|
||||||
EVENT_CLI_COMMAND_EXECUTED = "CLI execution succeeded"
|
EVENT_CLI_COMMAND_EXECUTED = "CLI execution succeeded"
|
||||||
EVENT_CLI_COMMAND_FAILED = "CLI execution failed"
|
EVENT_CLI_COMMAND_FAILED = "CLI execution failed"
|
||||||
|
|
||||||
# Property Names
|
# CLI Specific Property Names
|
||||||
PROP_COMMAND_NAME = "command_name"
|
PROP_COMMAND_NAME = "command_name"
|
||||||
PROP_CLI_VERSION = "cli_version"
|
PROP_CLI_VERSION = "cli_version"
|
||||||
PROP_RUNTIME_LANGUAGE = "runtime_language"
|
|
||||||
PROP_RUNTIME_VERSION = "runtime_version"
|
|
||||||
PROP_OS_TYPE = "os_type"
|
|
||||||
PROP_OS_RELEASE = "os_release"
|
|
||||||
PROP_DURATION_MS = "duration_ms"
|
|
||||||
PROP_ERROR_MESSAGE = "error_message"
|
|
||||||
PROP_DEVICE_MONOTONIC_START = "device_start_timestamp"
|
|
||||||
PROP_DEVICE_MONOTONIC_END = "device_end_timestamp"
|
|
||||||
# Only used for anonymous usage
|
|
||||||
PROP_PROCESS_PERSON_PROFILE = "$process_person_profile"
|
|
||||||
|
|
||||||
# Identity Keys
|
|
||||||
KEY_ANON_ID = "anon_id"
|
|
||||||
KEY_LINKED_PRINCIPAL_ID = "linked_principal_id"
|
|
||||||
|
|
||||||
# File Names
|
|
||||||
USAGE_FILE_NAME = "usage.json"
|
|
||||||
|
|
||||||
# Environment Variables
|
|
||||||
# how props are passed to the usage tracking subprocess
|
|
||||||
ARCADE_USAGE_EVENT_DATA = "ARCADE_USAGE_EVENT_DATA"
|
|
||||||
# whether usage tracking is enabled. 1 is enabled, 0 is disabled.
|
|
||||||
ARCADE_USAGE_TRACKING = "ARCADE_USAGE_TRACKING"
|
|
||||||
|
|
||||||
# Timeouts and Limits (in seconds)
|
|
||||||
TIMEOUT_POSTHOG_ALIAS = 2
|
|
||||||
TIMEOUT_POSTHOG_CAPTURE = 5
|
|
||||||
TIMEOUT_ARCADE_API = 2.0
|
|
||||||
TIMEOUT_SUBPROCESS_EXIT = 10.0
|
|
||||||
|
|
||||||
# Retry Configuration
|
|
||||||
MAX_RETRIES_POSTHOG = 1
|
|
||||||
|
|
|
||||||
6
libs/arcade-core/arcade_core/constants.py
Normal file
6
libs/arcade-core/arcade_core/constants.py
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import os
|
||||||
|
|
||||||
|
# The path to the directory containing the Arcade configuration files. Typically ~/.arcade
|
||||||
|
ARCADE_CONFIG_PATH = os.path.join(os.path.expanduser(os.getenv("ARCADE_WORK_DIR", "~")), ".arcade")
|
||||||
|
# The path to the file containing the user's Arcade-related credentials (e.g., ARCADE_API_KEY).
|
||||||
|
CREDENTIALS_FILE_PATH = os.path.join(ARCADE_CONFIG_PATH, "credentials.yaml")
|
||||||
5
libs/arcade-core/arcade_core/usage/__init__.py
Normal file
5
libs/arcade-core/arcade_core/usage/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
from arcade_core.usage.identity import UsageIdentity
|
||||||
|
from arcade_core.usage.usage_service import UsageService
|
||||||
|
from arcade_core.usage.utils import is_tracking_enabled
|
||||||
|
|
||||||
|
__all__ = ["UsageIdentity", "UsageService", "is_tracking_enabled"]
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"""Entry point for detached usage tracking subprocess.
|
"""Entry point for detached usage tracking subprocess.
|
||||||
|
|
||||||
This module is invoked as `python -m arcade_cli.usage` and expects
|
This module is invoked as `python -m arcade_core.usage` and expects
|
||||||
event data to be passed via the ARCADE_USAGE_EVENT_DATA environment variable.
|
event data to be passed via the ARCADE_USAGE_EVENT_DATA environment variable.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -8,14 +8,15 @@ import json
|
||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
from arcade_cli.usage.constants import (
|
from posthog import Posthog
|
||||||
|
|
||||||
|
from arcade_core.usage.constants import (
|
||||||
ARCADE_USAGE_EVENT_DATA,
|
ARCADE_USAGE_EVENT_DATA,
|
||||||
MAX_RETRIES_POSTHOG,
|
MAX_RETRIES_POSTHOG,
|
||||||
PROP_PROCESS_PERSON_PROFILE,
|
PROP_PROCESS_PERSON_PROFILE,
|
||||||
TIMEOUT_POSTHOG_CAPTURE,
|
TIMEOUT_POSTHOG_CAPTURE,
|
||||||
TIMEOUT_SUBPROCESS_EXIT,
|
TIMEOUT_SUBPROCESS_EXIT,
|
||||||
)
|
)
|
||||||
from posthog import Posthog
|
|
||||||
|
|
||||||
|
|
||||||
def _timeout_exit() -> None:
|
def _timeout_exit() -> None:
|
||||||
34
libs/arcade-core/arcade_core/usage/constants.py
Normal file
34
libs/arcade-core/arcade_core/usage/constants.py
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
# Base (common) Property Names
|
||||||
|
PROP_RUNTIME_LANGUAGE = "runtime_language"
|
||||||
|
PROP_RUNTIME_VERSION = "runtime_version"
|
||||||
|
PROP_OS_TYPE = "os_type"
|
||||||
|
PROP_OS_RELEASE = "os_release"
|
||||||
|
PROP_DURATION_MS = "duration_ms"
|
||||||
|
PROP_ERROR_MESSAGE = "error_message"
|
||||||
|
PROP_DEVICE_MONOTONIC_START = "device_start_timestamp"
|
||||||
|
PROP_DEVICE_MONOTONIC_END = "device_end_timestamp"
|
||||||
|
PROP_DEVICE_TIMESTAMP = "device_timestamp"
|
||||||
|
# Only used for anonymous usage
|
||||||
|
PROP_PROCESS_PERSON_PROFILE = "$process_person_profile"
|
||||||
|
|
||||||
|
# Identity Keys
|
||||||
|
KEY_ANON_ID = "anon_id"
|
||||||
|
KEY_LINKED_PRINCIPAL_ID = "linked_principal_id"
|
||||||
|
|
||||||
|
# File Names
|
||||||
|
USAGE_FILE_NAME = "usage.json"
|
||||||
|
|
||||||
|
# Environment Variables
|
||||||
|
# how props are passed to the usage tracking subprocess
|
||||||
|
ARCADE_USAGE_EVENT_DATA = "ARCADE_USAGE_EVENT_DATA"
|
||||||
|
# whether usage tracking is enabled. 1 is enabled, 0 is disabled.
|
||||||
|
ARCADE_USAGE_TRACKING = "ARCADE_USAGE_TRACKING"
|
||||||
|
|
||||||
|
# Timeouts and Limits (in seconds)
|
||||||
|
TIMEOUT_POSTHOG_ALIAS = 2
|
||||||
|
TIMEOUT_POSTHOG_CAPTURE = 5
|
||||||
|
TIMEOUT_ARCADE_API = 2.0
|
||||||
|
TIMEOUT_SUBPROCESS_EXIT = 10.0
|
||||||
|
|
||||||
|
# Retry Configuration
|
||||||
|
MAX_RETRIES_POSTHOG = 1
|
||||||
|
|
@ -15,8 +15,9 @@ from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import yaml
|
import yaml
|
||||||
from arcade_cli.constants import ARCADE_CONFIG_PATH, CREDENTIALS_FILE_PATH
|
|
||||||
from arcade_cli.usage.constants import (
|
from arcade_core.constants import ARCADE_CONFIG_PATH, CREDENTIALS_FILE_PATH
|
||||||
|
from arcade_core.usage.constants import (
|
||||||
KEY_ANON_ID,
|
KEY_ANON_ID,
|
||||||
KEY_LINKED_PRINCIPAL_ID,
|
KEY_LINKED_PRINCIPAL_ID,
|
||||||
TIMEOUT_ARCADE_API,
|
TIMEOUT_ARCADE_API,
|
||||||
|
|
@ -3,17 +3,17 @@ import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from arcade_cli.usage.constants import (
|
from arcade_core.usage.constants import (
|
||||||
ARCADE_USAGE_EVENT_DATA,
|
ARCADE_USAGE_EVENT_DATA,
|
||||||
MAX_RETRIES_POSTHOG,
|
MAX_RETRIES_POSTHOG,
|
||||||
TIMEOUT_POSTHOG_ALIAS,
|
TIMEOUT_POSTHOG_ALIAS,
|
||||||
)
|
)
|
||||||
from arcade_cli.usage.utils import is_tracking_enabled
|
from arcade_core.usage.utils import is_tracking_enabled
|
||||||
|
|
||||||
|
|
||||||
class UsageService:
|
class UsageService:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.api_key = "phc_hIqUQyJpf2TP4COePO5jEpkGeUXipa7KqTEyDeRsTmB"
|
self.api_key = "phc_g7OuFqZEAVwIgRdtnZkjvBpy9weQ1f9VJW6YP1SzQRF"
|
||||||
self.host = "https://us.i.posthog.com"
|
self.host = "https://us.i.posthog.com"
|
||||||
|
|
||||||
def alias(self, previous_id: str, distinct_id: str) -> None:
|
def alias(self, previous_id: str, distinct_id: str) -> None:
|
||||||
|
|
@ -71,7 +71,7 @@ class UsageService:
|
||||||
"is_anon": is_anon,
|
"is_anon": is_anon,
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd = [sys.executable, "-m", "arcade_cli.usage"]
|
cmd = [sys.executable, "-m", "arcade_core.usage"]
|
||||||
|
|
||||||
# Pass data via environment variable (works on all platforms)
|
# Pass data via environment variable (works on all platforms)
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from arcade_cli.usage.constants import ARCADE_USAGE_TRACKING
|
from arcade_core.usage.constants import ARCADE_USAGE_TRACKING
|
||||||
|
|
||||||
|
|
||||||
def is_tracking_enabled() -> bool:
|
def is_tracking_enabled() -> bool:
|
||||||
|
|
@ -67,6 +67,29 @@ python -m arcade_mcp_server --host 0.0.0.0 --port 8080
|
||||||
- [Discord Community](https://discord.gg/arcade-mcp)
|
- [Discord Community](https://discord.gg/arcade-mcp)
|
||||||
- [Documentation](https://docs.arcade.dev)
|
- [Documentation](https://docs.arcade.dev)
|
||||||
|
|
||||||
|
## Analytics & Privacy
|
||||||
|
|
||||||
|
*Arcade MCP Server* collects anonymous usage data to help us improve the service and debug issues. We track "MCP server start" events to understand server usage patterns and reliability.
|
||||||
|
|
||||||
|
#### What We Track
|
||||||
|
|
||||||
|
When the server starts, we collect the following information:
|
||||||
|
- **Server configuration**: transport type (`http` or `stdio`), host, port
|
||||||
|
- **Server metadata**: tool count, server version
|
||||||
|
- **Runtime environment**: Python version, OS type and release
|
||||||
|
- **Timing**: device timestamp
|
||||||
|
- **Errors**: error messages (if startup fails)
|
||||||
|
|
||||||
|
#### Privacy
|
||||||
|
|
||||||
|
- For **anonymous users**: Events are tracked with an anonymous ID and no user profile is created
|
||||||
|
- For **authenticated users**: Events are linked to your account to help us provide better support
|
||||||
|
- **No sensitive data** (credentials, tool inputs/outputs, or personal information) is ever collected
|
||||||
|
|
||||||
|
#### Opt Out
|
||||||
|
|
||||||
|
To disable usage tracking, set the environment variable ARCADE_USAGE_TRACKING to 0.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Arcade MCP Server is open source software licensed under the MIT license.
|
Arcade MCP Server is open source software licensed under the MIT license.
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ from arcade_mcp_server.exceptions import ServerError
|
||||||
from arcade_mcp_server.server import MCPServer
|
from arcade_mcp_server.server import MCPServer
|
||||||
from arcade_mcp_server.settings import MCPSettings, ServerSettings
|
from arcade_mcp_server.settings import MCPSettings, ServerSettings
|
||||||
from arcade_mcp_server.types import Prompt, PromptMessage, Resource
|
from arcade_mcp_server.types import Prompt, PromptMessage, Resource
|
||||||
|
from arcade_mcp_server.usage import ServerTracker
|
||||||
from arcade_mcp_server.worker import create_arcade_mcp
|
from arcade_mcp_server.worker import create_arcade_mcp
|
||||||
|
|
||||||
P = ParamSpec("P")
|
P = ParamSpec("P")
|
||||||
|
|
@ -249,6 +250,13 @@ class MCPApp:
|
||||||
elif transport == "stdio":
|
elif transport == "stdio":
|
||||||
from arcade_mcp_server.__main__ import run_stdio_server
|
from arcade_mcp_server.__main__ import run_stdio_server
|
||||||
|
|
||||||
|
tracker = ServerTracker()
|
||||||
|
tracker.track_server_start(
|
||||||
|
transport="stdio",
|
||||||
|
host=None,
|
||||||
|
port=None,
|
||||||
|
tool_count=len(self._catalog),
|
||||||
|
)
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
run_stdio_server(
|
run_stdio_server(
|
||||||
catalog=self._catalog,
|
catalog=self._catalog,
|
||||||
|
|
@ -328,6 +336,13 @@ class MCPApp:
|
||||||
**self.server_kwargs,
|
**self.server_kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
tracker = ServerTracker()
|
||||||
|
tracker.track_server_start(
|
||||||
|
transport="http",
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
tool_count=len(self._catalog),
|
||||||
|
)
|
||||||
uvicorn.run(
|
uvicorn.run(
|
||||||
app,
|
app,
|
||||||
host=host,
|
host=host,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
from arcade_mcp_server.usage.server_tracker import ServerTracker
|
||||||
|
|
||||||
|
__all__ = ["ServerTracker"]
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
# MCP Server Specific Event Names
|
||||||
|
EVENT_MCP_SERVER_STARTED = "MCP server started"
|
||||||
|
|
||||||
|
# MCP Server Specific Property Names
|
||||||
|
PROP_TRANSPORT = "transport"
|
||||||
|
PROP_HOST = "host"
|
||||||
|
PROP_PORT = "port"
|
||||||
|
PROP_TOOL_COUNT = "tool_count"
|
||||||
|
PROP_MCP_SERVER_VERSION = "arcade_mcp_server_version"
|
||||||
109
libs/arcade-mcp-server/arcade_mcp_server/usage/server_tracker.py
Normal file
109
libs/arcade-mcp-server/arcade_mcp_server/usage/server_tracker.py
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import platform
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from importlib import metadata
|
||||||
|
|
||||||
|
from arcade_core.usage import UsageIdentity, UsageService, is_tracking_enabled
|
||||||
|
from arcade_core.usage.constants import (
|
||||||
|
PROP_DEVICE_TIMESTAMP,
|
||||||
|
PROP_OS_RELEASE,
|
||||||
|
PROP_OS_TYPE,
|
||||||
|
PROP_RUNTIME_LANGUAGE,
|
||||||
|
PROP_RUNTIME_VERSION,
|
||||||
|
)
|
||||||
|
|
||||||
|
from arcade_mcp_server.usage.constants import (
|
||||||
|
EVENT_MCP_SERVER_STARTED,
|
||||||
|
PROP_HOST,
|
||||||
|
PROP_MCP_SERVER_VERSION,
|
||||||
|
PROP_PORT,
|
||||||
|
PROP_TOOL_COUNT,
|
||||||
|
PROP_TRANSPORT,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ServerTracker:
|
||||||
|
"""Tracks MCP server events for usage analytics.
|
||||||
|
|
||||||
|
To opt out, set the ARCADE_USAGE_TRACKING environment variable to 0.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.usage_service = UsageService()
|
||||||
|
self.identity = UsageIdentity()
|
||||||
|
self._mcp_server_version: str | None = None
|
||||||
|
self._runtime_version: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mcp_server_version(self) -> str:
|
||||||
|
"""Get the version of arcade_mcp_server package"""
|
||||||
|
if self._mcp_server_version is None:
|
||||||
|
try:
|
||||||
|
self._mcp_server_version = metadata.version("arcade-mcp-server")
|
||||||
|
except Exception:
|
||||||
|
self._mcp_server_version = "unknown"
|
||||||
|
return self._mcp_server_version
|
||||||
|
|
||||||
|
@property
|
||||||
|
def runtime_version(self) -> str:
|
||||||
|
"""Get the version of the Python runtime"""
|
||||||
|
if self._runtime_version is None:
|
||||||
|
version_info = sys.version_info
|
||||||
|
self._runtime_version = (
|
||||||
|
f"{version_info.major}.{version_info.minor}.{version_info.micro}"
|
||||||
|
)
|
||||||
|
return self._runtime_version
|
||||||
|
|
||||||
|
@property
|
||||||
|
def user_id(self) -> str:
|
||||||
|
"""Get the distinct_id based on developer's authentication state"""
|
||||||
|
return self.identity.get_distinct_id()
|
||||||
|
|
||||||
|
def track_server_start(
|
||||||
|
self,
|
||||||
|
transport: str,
|
||||||
|
host: str | None,
|
||||||
|
port: int | None,
|
||||||
|
tool_count: int,
|
||||||
|
) -> None:
|
||||||
|
"""Track MCP server start event.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
transport: The transport type ("http" or "stdio")
|
||||||
|
host: The host address (None for stdio)
|
||||||
|
port: The port number (None for stdio)
|
||||||
|
tool_count: The number of tools available at server start
|
||||||
|
"""
|
||||||
|
if not is_tracking_enabled():
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if aliasing needed (user authenticated but not yet linked)
|
||||||
|
if self.identity.should_alias():
|
||||||
|
principal_id = self.identity.get_principal_id()
|
||||||
|
if principal_id:
|
||||||
|
self.usage_service.alias(
|
||||||
|
previous_id=self.identity.anon_id, distinct_id=principal_id
|
||||||
|
)
|
||||||
|
self.identity.set_linked_principal_id(principal_id)
|
||||||
|
|
||||||
|
properties: dict[str, str | int | float] = {
|
||||||
|
PROP_TRANSPORT: transport,
|
||||||
|
PROP_TOOL_COUNT: tool_count,
|
||||||
|
PROP_MCP_SERVER_VERSION: self.mcp_server_version,
|
||||||
|
PROP_RUNTIME_LANGUAGE: "python",
|
||||||
|
PROP_RUNTIME_VERSION: self.runtime_version,
|
||||||
|
PROP_OS_TYPE: platform.system(),
|
||||||
|
PROP_OS_RELEASE: platform.release(),
|
||||||
|
PROP_DEVICE_TIMESTAMP: time.monotonic(),
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTP Streamable specific props
|
||||||
|
if host is not None:
|
||||||
|
properties[PROP_HOST] = host
|
||||||
|
if port is not None:
|
||||||
|
properties[PROP_PORT] = port
|
||||||
|
|
||||||
|
is_anon = self.user_id == self.identity.anon_id
|
||||||
|
self.usage_service.capture(
|
||||||
|
EVENT_MCP_SERVER_STARTED, self.user_id, properties=properties, is_anon=is_anon
|
||||||
|
)
|
||||||
|
|
@ -5,7 +5,7 @@ from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import yaml
|
import yaml
|
||||||
from arcade_cli.usage.identity import UsageIdentity
|
from arcade_core.usage import UsageIdentity
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
@ -15,8 +15,8 @@ def temp_config_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||||
config_dir.mkdir()
|
config_dir.mkdir()
|
||||||
credentials_file = config_dir / "credentials.yaml"
|
credentials_file = config_dir / "credentials.yaml"
|
||||||
|
|
||||||
monkeypatch.setattr("arcade_cli.usage.identity.ARCADE_CONFIG_PATH", str(config_dir))
|
monkeypatch.setattr("arcade_core.usage.identity.ARCADE_CONFIG_PATH", str(config_dir))
|
||||||
monkeypatch.setattr("arcade_cli.usage.identity.CREDENTIALS_FILE_PATH", str(credentials_file))
|
monkeypatch.setattr("arcade_core.usage.identity.CREDENTIALS_FILE_PATH", str(credentials_file))
|
||||||
|
|
||||||
return config_dir
|
return config_dir
|
||||||
|
|
||||||
|
|
@ -154,7 +154,7 @@ class TestGetDistinctId:
|
||||||
|
|
||||||
assert distinct_id == "persisted-user-123"
|
assert distinct_id == "persisted-user-123"
|
||||||
|
|
||||||
@patch("arcade_cli.usage.identity.UsageIdentity.get_principal_id")
|
@patch("arcade_core.usage.identity.UsageIdentity.get_principal_id")
|
||||||
def test_returns_principal_id_from_api_when_not_persisted(
|
def test_returns_principal_id_from_api_when_not_persisted(
|
||||||
self, mock_get_principal: MagicMock, identity: UsageIdentity
|
self, mock_get_principal: MagicMock, identity: UsageIdentity
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
@ -166,7 +166,7 @@ class TestGetDistinctId:
|
||||||
assert distinct_id == "api-user-456"
|
assert distinct_id == "api-user-456"
|
||||||
mock_get_principal.assert_called_once()
|
mock_get_principal.assert_called_once()
|
||||||
|
|
||||||
@patch("arcade_cli.usage.identity.UsageIdentity.get_principal_id")
|
@patch("arcade_core.usage.identity.UsageIdentity.get_principal_id")
|
||||||
def test_returns_anon_id_when_not_authenticated(
|
def test_returns_anon_id_when_not_authenticated(
|
||||||
self, mock_get_principal: MagicMock, identity: UsageIdentity
|
self, mock_get_principal: MagicMock, identity: UsageIdentity
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
@ -257,7 +257,7 @@ class TestGetPrincipalId:
|
||||||
class TestShouldAlias:
|
class TestShouldAlias:
|
||||||
"""Tests for should_alias() method."""
|
"""Tests for should_alias() method."""
|
||||||
|
|
||||||
@patch("arcade_cli.usage.identity.UsageIdentity.get_principal_id")
|
@patch("arcade_core.usage.identity.UsageIdentity.get_principal_id")
|
||||||
def test_returns_true_when_authenticated_but_not_linked(
|
def test_returns_true_when_authenticated_but_not_linked(
|
||||||
self, mock_get_principal: MagicMock, identity: UsageIdentity
|
self, mock_get_principal: MagicMock, identity: UsageIdentity
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
@ -268,7 +268,7 @@ class TestShouldAlias:
|
||||||
|
|
||||||
assert should_alias is True
|
assert should_alias is True
|
||||||
|
|
||||||
@patch("arcade_cli.usage.identity.UsageIdentity.get_principal_id")
|
@patch("arcade_core.usage.identity.UsageIdentity.get_principal_id")
|
||||||
def test_returns_false_when_already_linked(
|
def test_returns_false_when_already_linked(
|
||||||
self, mock_get_principal: MagicMock, identity: UsageIdentity, temp_config_path: Path
|
self, mock_get_principal: MagicMock, identity: UsageIdentity, temp_config_path: Path
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
@ -285,7 +285,7 @@ class TestShouldAlias:
|
||||||
|
|
||||||
assert should_alias is False
|
assert should_alias is False
|
||||||
|
|
||||||
@patch("arcade_cli.usage.identity.UsageIdentity.get_principal_id")
|
@patch("arcade_core.usage.identity.UsageIdentity.get_principal_id")
|
||||||
def test_returns_false_when_not_authenticated(
|
def test_returns_false_when_not_authenticated(
|
||||||
self, mock_get_principal: MagicMock, identity: UsageIdentity
|
self, mock_get_principal: MagicMock, identity: UsageIdentity
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import pytest
|
import pytest
|
||||||
from arcade_cli.usage.utils import is_tracking_enabled
|
from arcade_core.usage import is_tracking_enabled
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
Loading…
Reference in a new issue