diff --git a/libs/cli/langgraph_cli/analytics.py b/libs/cli/langgraph_cli/analytics.py new file mode 100644 index 000000000..0495d268a --- /dev/null +++ b/libs/cli/langgraph_cli/analytics.py @@ -0,0 +1,98 @@ +import functools +import json +import os +import pathlib +import platform +import threading +import urllib.error +import urllib.request +from typing import Any, TypedDict + +from langgraph_cli.constants import ( + DEFAULT_CONFIG, + DEFAULT_PORT, + SUPABASE_PUBLIC_API_KEY, + SUPABASE_URL, +) +from langgraph_cli.version import __version__ + + +class LogData(TypedDict): + os: str + os_version: str + python_version: str + cli_version: str + cli_command: str + params: dict[str, Any] + + +def get_anonymized_params(kwargs: dict[str, Any]) -> dict[str, bool]: + params = {} + + # anonymize params with values + if config := kwargs.get("config"): + if config != pathlib.Path(DEFAULT_CONFIG).resolve(): + params["config"] = True + + if port := kwargs.get("port"): + if port != DEFAULT_PORT: + params["port"] = True + + if kwargs.get("docker_compose"): + params["docker_compose"] = True + + if kwargs.get("debugger_port"): + params["debugger_port"] = True + + if kwargs.get("postgres_uri"): + params["postgres_uri"] = True + + # pick up exact values for boolean flags + for boolean_param in ["recreate", "pull", "watch", "wait", "verbose"]: + if kwargs.get(boolean_param): + params[boolean_param] = kwargs[boolean_param] + + return params + + +def log_data(data: LogData) -> None: + headers = { + "Content-Type": "application/json", + "apikey": SUPABASE_PUBLIC_API_KEY, + "User-Agent": "Mozilla/5.0", + } + supabase_url = SUPABASE_URL + + req = urllib.request.Request( + f"{supabase_url}/rest/v1/logs", + data=json.dumps(data).encode("utf-8"), + headers=headers, + method="POST", + ) + + try: + urllib.request.urlopen(req) + except urllib.error.URLError: + pass + + +def log_command(func): + @functools.wraps(func) + def decorator(*args, **kwargs): + if os.getenv("LANGGRAPH_CLI_NO_ANALYTICS") == "1": + return func(*args, **kwargs) + + data = { + "os": platform.system(), + "os_version": platform.version(), + "python_version": platform.python_version(), + "cli_version": __version__, + "cli_command": func.__name__, + "params": get_anonymized_params(kwargs), + } + + background_thread = threading.Thread(target=log_data, args=(data,)) + background_thread.start() + return func(*args, **kwargs) + + return decorator diff --git a/libs/cli/langgraph_cli/cli.py b/libs/cli/langgraph_cli/cli.py index 35f665b88..d4b0f5ffa 100644 --- a/libs/cli/langgraph_cli/cli.py +++ b/libs/cli/langgraph_cli/cli.py @@ -9,7 +9,9 @@ import click.exceptions import langgraph_cli.config import langgraph_cli.docker +from langgraph_cli.analytics import log_command from langgraph_cli.config import Config +from langgraph_cli.constants import DEFAULT_CONFIG, DEFAULT_PORT from langgraph_cli.docker import DockerCapabilities from langgraph_cli.exec import Runner, subp_exec from langgraph_cli.progress import Progress @@ -79,7 +81,7 @@ OPT_CONFIG = click.option( } Defaults to looking for langgraph.json in the current directory.""", - default="langgraph.json", + default=DEFAULT_CONFIG, type=click.Path( exists=True, file_okay=True, @@ -92,7 +94,7 @@ OPT_PORT = click.option( "--port", "-p", type=int, - default=8123, + default=DEFAULT_PORT, show_default=True, help=""" Port to expose. @@ -166,6 +168,7 @@ def cli(): help="Wait for services to start before returning. Implies --detach", ) @cli.command(help="Start langgraph API server") +@log_command def up( config: pathlib.Path, docker_compose: Optional[pathlib.Path], @@ -254,6 +257,7 @@ def up( @OPT_VERBOSE @OPT_DEBUGGER_PORT @cli.command(help="Stop langgraph API server") +@log_command def down( config: pathlib.Path, docker_compose: Optional[pathlib.Path], @@ -290,6 +294,7 @@ def down( @OPT_CONFIG @click.option("--follow", "-f", is_flag=True, help="Follow logs") @cli.command(help="Show langgraph API server logs") +@log_command def logs( config: pathlib.Path, docker_compose: Optional[pathlib.Path], @@ -347,6 +352,7 @@ def logs( """, ) @cli.command(help="Build langgraph API server docker image") +@log_command def build( config: pathlib.Path, platform: Optional[str], @@ -409,6 +415,7 @@ def export(): @OPT_WATCH @OPT_LANGGRAPH_API_PATH @export.command(name="compose", help="Export docker compose file") +@log_command def export_compose( output: pathlib.Path, config: pathlib.Path, @@ -455,6 +462,7 @@ def export_compose( help="Build and export a helm chart to deploy to a Kubernetes cluster", hidden=True, ) +@log_command def export_helm( output: pathlib.Path, config: pathlib.Path, diff --git a/libs/cli/langgraph_cli/constants.py b/libs/cli/langgraph_cli/constants.py new file mode 100644 index 000000000..117062a97 --- /dev/null +++ b/libs/cli/langgraph_cli/constants.py @@ -0,0 +1,6 @@ +DEFAULT_CONFIG = "langgraph.json" +DEFAULT_PORT = 8123 + +# analytics +SUPABASE_PUBLIC_API_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imt6cmxwcG9qaW5wY3l5YWlweG5iIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MTkyNTc1NzksImV4cCI6MjAzNDgzMzU3OX0.kkVOlLz3BxemA5nP-vat3K4qRtrDuO4SwZSR_htcX9c" +SUPABASE_URL = "https://kzrlppojinpcyyaipxnb.supabase.co" diff --git a/libs/cli/langgraph_cli/version.py b/libs/cli/langgraph_cli/version.py new file mode 100644 index 000000000..3368893c0 --- /dev/null +++ b/libs/cli/langgraph_cli/version.py @@ -0,0 +1,10 @@ +"""Main entrypoint into package.""" + +from importlib import metadata + +try: + __version__ = metadata.version(__package__) +except metadata.PackageNotFoundError: + # Case where package metadata is not available. + __version__ = "" +del metadata # optional, avoids polluting the results of dir(__package__)