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:
Eric Gustin 2025-10-22 16:14:52 -07:00 committed by GitHub
parent 66a126bba5
commit 49e53d2b33
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 236 additions and 73 deletions

View file

@ -5,11 +5,13 @@ from typing import Any
from urllib.parse import parse_qs
import yaml
from arcade_core.constants import (
ARCADE_CONFIG_PATH,
CREDENTIALS_FILE_PATH,
)
from rich.console import Console
from arcade_cli.constants import (
ARCADE_CONFIG_PATH,
CREDENTIALS_FILE_PATH,
LOGIN_FAILED_HTML,
LOGIN_SUCCESS_HTML,
)

View file

@ -1,19 +1,8 @@
import os
PROD_CLOUD_HOST = "cloud.arcade.dev"
PROD_ENGINE_HOST = "api.arcade.dev"
LOCALHOST = "localhost"
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"""
<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">

View file

@ -10,6 +10,7 @@ from typing import Optional
import click
import typer
from arcade_core.constants import CREDENTIALS_FILE_PATH
from arcadepy import Arcade
from rich.console import Console
from rich.text import Text
@ -19,7 +20,6 @@ import arcade_cli.secret as secret
import arcade_cli.worker as worker
from arcade_cli.authn import LocalAuthCallbackServer, check_existing_login
from arcade_cli.constants import (
CREDENTIALS_FILE_PATH,
PROD_CLOUD_HOST,
PROD_ENGINE_HOST,
)

View file

@ -7,12 +7,15 @@ from importlib import metadata
from typing import Any
import typer
from arcade_cli.constants import ARCADE_CONFIG_PATH
from arcade_cli.usage.constants import (
EVENT_CLI_COMMAND_EXECUTED,
EVENT_CLI_COMMAND_FAILED,
PROP_CLI_VERSION,
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_START,
PROP_DURATION_MS,
@ -22,9 +25,6 @@ from arcade_cli.usage.constants import (
PROP_RUNTIME_LANGUAGE,
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 typer.core import TyperCommand, TyperGroup
from typer.models import Context

View file

@ -1,41 +1,7 @@
"""Constants for usage tracking and analytics."""
# Event Names
# CLI Specific Event Names
EVENT_CLI_COMMAND_EXECUTED = "CLI execution succeeded"
EVENT_CLI_COMMAND_FAILED = "CLI execution failed"
# Property Names
# CLI Specific Property Names
PROP_COMMAND_NAME = "command_name"
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

View 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")

View 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"]

View file

@ -1,6 +1,6 @@
"""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.
"""
@ -8,14 +8,15 @@ import json
import os
import threading
from arcade_cli.usage.constants import (
from posthog import Posthog
from arcade_core.usage.constants import (
ARCADE_USAGE_EVENT_DATA,
MAX_RETRIES_POSTHOG,
PROP_PROCESS_PERSON_PROFILE,
TIMEOUT_POSTHOG_CAPTURE,
TIMEOUT_SUBPROCESS_EXIT,
)
from posthog import Posthog
def _timeout_exit() -> None:

View 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

View file

@ -15,8 +15,9 @@ from typing import Any
import httpx
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_LINKED_PRINCIPAL_ID,
TIMEOUT_ARCADE_API,

View file

@ -3,17 +3,17 @@ import os
import subprocess
import sys
from arcade_cli.usage.constants import (
from arcade_core.usage.constants import (
ARCADE_USAGE_EVENT_DATA,
MAX_RETRIES_POSTHOG,
TIMEOUT_POSTHOG_ALIAS,
)
from arcade_cli.usage.utils import is_tracking_enabled
from arcade_core.usage.utils import is_tracking_enabled
class UsageService:
def __init__(self) -> None:
self.api_key = "phc_hIqUQyJpf2TP4COePO5jEpkGeUXipa7KqTEyDeRsTmB"
self.api_key = "phc_g7OuFqZEAVwIgRdtnZkjvBpy9weQ1f9VJW6YP1SzQRF"
self.host = "https://us.i.posthog.com"
def alias(self, previous_id: str, distinct_id: str) -> None:
@ -71,7 +71,7 @@ class UsageService:
"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)
env = os.environ.copy()

View file

@ -1,6 +1,6 @@
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:

View file

@ -67,6 +67,29 @@ python -m arcade_mcp_server --host 0.0.0.0 --port 8080
- [Discord Community](https://discord.gg/arcade-mcp)
- [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
Arcade MCP Server is open source software licensed under the MIT license.

View file

@ -26,6 +26,7 @@ from arcade_mcp_server.exceptions import ServerError
from arcade_mcp_server.server import MCPServer
from arcade_mcp_server.settings import MCPSettings, ServerSettings
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
P = ParamSpec("P")
@ -249,6 +250,13 @@ class MCPApp:
elif transport == "stdio":
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(
run_stdio_server(
catalog=self._catalog,
@ -328,6 +336,13 @@ class MCPApp:
**self.server_kwargs,
)
tracker = ServerTracker()
tracker.track_server_start(
transport="http",
host=host,
port=port,
tool_count=len(self._catalog),
)
uvicorn.run(
app,
host=host,

View file

@ -0,0 +1,3 @@
from arcade_mcp_server.usage.server_tracker import ServerTracker
__all__ = ["ServerTracker"]

View file

@ -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"

View 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
)

View file

@ -5,7 +5,7 @@ from unittest.mock import MagicMock, patch
import pytest
import yaml
from arcade_cli.usage.identity import UsageIdentity
from arcade_core.usage import UsageIdentity
@pytest.fixture
@ -15,8 +15,8 @@ def temp_config_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
config_dir.mkdir()
credentials_file = config_dir / "credentials.yaml"
monkeypatch.setattr("arcade_cli.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.ARCADE_CONFIG_PATH", str(config_dir))
monkeypatch.setattr("arcade_core.usage.identity.CREDENTIALS_FILE_PATH", str(credentials_file))
return config_dir
@ -154,7 +154,7 @@ class TestGetDistinctId:
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(
self, mock_get_principal: MagicMock, identity: UsageIdentity
) -> None:
@ -166,7 +166,7 @@ class TestGetDistinctId:
assert distinct_id == "api-user-456"
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(
self, mock_get_principal: MagicMock, identity: UsageIdentity
) -> None:
@ -257,7 +257,7 @@ class TestGetPrincipalId:
class TestShouldAlias:
"""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(
self, mock_get_principal: MagicMock, identity: UsageIdentity
) -> None:
@ -268,7 +268,7 @@ class TestShouldAlias:
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(
self, mock_get_principal: MagicMock, identity: UsageIdentity, temp_config_path: Path
) -> None:
@ -285,7 +285,7 @@ class TestShouldAlias:
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(
self, mock_get_principal: MagicMock, identity: UsageIdentity
) -> None:

View file

@ -1,5 +1,5 @@
import pytest
from arcade_cli.usage.utils import is_tracking_enabled
from arcade_core.usage import is_tracking_enabled
@pytest.mark.parametrize(