Skip to content

Commit d4bf793

Browse files
committed
feat(cli): Add Analytic tracking to CLI commands
1 parent 57dcc89 commit d4bf793

45 files changed

Lines changed: 287 additions & 21 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ dependencies = [
2525
"filelock>=3.13.1",
2626
"types-pyyaml>=6.0.12.20250915",
2727
"tomli>=2.0.0; python_version < '3.11'",
28+
"py-machineid>=1.0.0",
29+
"detect-agent>=0.1.0",
2830
]
2931

3032
requires-python = ">= 3.9"

src/together/lib/cli/__init__.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from together.lib.cli.api.beta import beta
1515
from together.lib.cli.api.evals import evals
1616
from together.lib.cli.api.files import files
17+
from together.lib.cli._track_cli import CliTrackingEvents, track_cli
1718
from together.lib.cli.api.models import models
1819
from together.lib.cli.api.endpoints import endpoints
1920
from together.lib.cli.api.fine_tuning import fine_tuning
@@ -64,7 +65,7 @@ def main(
6465
setup_logging() # Must run this again here to allow the new logging configuration to take effect
6566

6667
try:
67-
ctx.obj = together.Together(
68+
client = together.Together(
6869
api_key=api_key,
6970
base_url=base_url,
7071
timeout=timeout,
@@ -79,7 +80,7 @@ def main(
7980
# Instead we opt to create a dummy client and hook into any requests performed by the client. We take that moment to print the error and exit.
8081
except Exception as e:
8182
if "api_key" in str(e):
82-
ctx.obj = together.Together(
83+
client = together.Together(
8384
api_key="0000000000000000000000000000000000000000",
8485
base_url=base_url,
8586
timeout=timeout,
@@ -98,11 +99,22 @@ def block_requests_for_api_key(_: httpx.Request) -> None:
9899
click.secho(f"\nUsage: together --api-key <your-api-key> {invoked_command_name}", fg="yellow")
99100
sys.exit(1)
100101

101-
ctx.obj._client.event_hooks["request"].append(block_requests_for_api_key)
102+
client._client.event_hooks["request"].append(block_requests_for_api_key)
102103
return
103104

104105
raise e
105106

107+
# Wrap the client's httpx requests to track the parameters sent on api requests
108+
def track_request(request: httpx.Request) -> None:
109+
track_cli(
110+
CliTrackingEvents.ApiRequest,
111+
{"url": str(request.url), "method": request.method, "body": request.content.decode("utf-8")},
112+
)
113+
114+
client._client.event_hooks["request"].append(track_request)
115+
116+
ctx.obj = client
117+
106118

107119
main.add_command(files)
108120
main.add_command(fine_tuning)
@@ -112,4 +124,12 @@ def block_requests_for_api_key(_: httpx.Request) -> None:
112124
main.add_command(beta)
113125

114126
if __name__ == "__main__":
115-
main()
127+
# When running the script, call the command with standalone_mode=False
128+
# to prevent Click's default top-level exception handling from suppressing
129+
# your try/except block.
130+
try:
131+
main(standalone_mode=False)
132+
except SystemExit as e:
133+
# Re-raise SystemExit if it's not a success exit (code 0)
134+
if e.code != 0:
135+
raise

src/together/lib/cli/_track_cli.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
from __future__ import annotations
2+
3+
import os
4+
import json
5+
import time
6+
import uuid
7+
import threading
8+
from enum import Enum
9+
from typing import Any, TypeVar, Callable
10+
from functools import wraps
11+
12+
import httpx
13+
import machineid
14+
from detect_agent import determine_agent
15+
16+
from together import __version__
17+
from together.lib.utils import log_debug
18+
19+
F = TypeVar("F", bound=Callable[..., Any])
20+
21+
SESSION_ID = int(str(uuid.uuid4().int)[0:13])
22+
23+
24+
def is_tracking_enabled() -> bool:
25+
# Users can opt-out of tracking with the environment variable.
26+
if os.getenv("TOGETHER_TELEMETRY_DISABLED"):
27+
log_debug("Analytics tracking disabled by environment variable")
28+
return False
29+
30+
return True
31+
32+
33+
class CliTrackingEvents(Enum):
34+
CommandStarted = "cli_command_started"
35+
CommandCompleted = "cli_command_completed"
36+
CommandFailed = "cli_command_failed"
37+
CommandUserAborted = "cli_command_user_aborted"
38+
ApiRequest = "cli_command_api_request"
39+
40+
41+
def track_cli(event_name: CliTrackingEvents, args: dict[str, Any]) -> None:
42+
"""Track a CLI event. Non-Blocking."""
43+
if is_tracking_enabled() == False:
44+
return
45+
46+
def send_event() -> None:
47+
ANALYTICS_API_ENV_VAR = os.getenv("TOGETHER_TELEMETRY_API")
48+
ANALYTICS_API = (
49+
ANALYTICS_API_ENV_VAR
50+
if ANALYTICS_API_ENV_VAR
51+
else "https://api.together.ai/together/gateway/pub/v1/httpRequest"
52+
)
53+
54+
try:
55+
agent_info = determine_agent()
56+
agent_name = ""
57+
if agent_info["agent"]:
58+
agent_name = agent_info["agent"]["name"]
59+
60+
log_debug("Analytics event sending", event_name=event_name.value, args=args)
61+
62+
client = httpx.Client()
63+
client.post(
64+
ANALYTICS_API,
65+
headers={"content-type": "application/json", "user-agent": f"together-cli:{__version__}"},
66+
content=json.dumps(
67+
{
68+
"event_source": "cli",
69+
"event_type": event_name.value,
70+
"event_properties": {
71+
"is_ci": os.getenv("CI") is not None,
72+
"is_agent": agent_info["is_agent"],
73+
"agent_name": agent_name,
74+
**args,
75+
},
76+
"context": {
77+
"session_id": str(SESSION_ID),
78+
"runtime": {
79+
"name": "together-cli",
80+
"version": __version__,
81+
},
82+
},
83+
"identity": {
84+
"device_id": machineid.id().lower(),
85+
},
86+
"event_options": {
87+
"time": int(time.time() * 1000),
88+
},
89+
}
90+
),
91+
)
92+
except Exception as e:
93+
log_debug("Error sending analytics event", error=e)
94+
# No-op - this is not critical and we don't want to block the CLI
95+
pass
96+
97+
threading.Thread(target=send_event).start()
98+
99+
100+
def auto_track_command(command: str) -> Callable[[F], F]:
101+
"""Decorator for click commands to automatically track CLI commands start/completion/failure."""
102+
103+
def decorator(f: F) -> F:
104+
@wraps(f)
105+
def wrapper(*args: Any, **kwargs: Any) -> Any:
106+
track_cli(CliTrackingEvents.CommandStarted, {"command": command, "arguments": kwargs})
107+
try:
108+
f(*args, **kwargs)
109+
except KeyboardInterrupt as e:
110+
track_cli(
111+
CliTrackingEvents.CommandUserAborted,
112+
{"command": command, "arguments": kwargs},
113+
)
114+
raise e
115+
116+
except Exception as e:
117+
track_cli(CliTrackingEvents.CommandFailed, {"command": command, "arguments": kwargs, "error": str(e)})
118+
raise e
119+
120+
track_cli(CliTrackingEvents.CommandCompleted, {"command": command, "arguments": kwargs})
121+
122+
return wrapper # type: ignore
123+
124+
return decorator # type: ignore

src/together/lib/cli/api/beta/clusters/create.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from rich import print
99

1010
from together import Together
11+
from together.lib.cli._track_cli import auto_track_command
1112
from together.lib.cli.api._utils import handle_api_errors
1213
from together.types.beta.cluster_create_params import SharedVolume, ClusterCreateParams
1314

@@ -61,6 +62,7 @@
6162
)
6263
@click.pass_context
6364
@handle_api_errors("Clusters")
65+
@auto_track_command("clusters create")
6466
def create(
6567
ctx: click.Context,
6668
name: str | None = None,

src/together/lib/cli/api/beta/clusters/delete.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import click
44

55
from together import Together
6+
from together.lib.cli._track_cli import auto_track_command
67
from together.lib.cli.api._utils import handle_api_errors
78

89

@@ -15,6 +16,7 @@
1516
)
1617
@click.pass_context
1718
@handle_api_errors("Clusters")
19+
@auto_track_command("clusters delete")
1820
def delete(ctx: click.Context, cluster_id: str, json: bool) -> None:
1921
"""Delete a cluster by ID"""
2022
client: Together = ctx.obj

src/together/lib/cli/api/beta/clusters/get_credentials.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import click
1111

1212
from together import Together, TogetherError
13+
from together.lib.cli._track_cli import auto_track_command
1314
from together.lib.cli.api._utils import handle_api_errors
1415

1516

@@ -39,6 +40,7 @@
3940
)
4041
@click.pass_context
4142
@handle_api_errors("Clusters")
43+
@auto_track_command("clusters get-credentials")
4244
def get_credentials(
4345
ctx: click.Context,
4446
cluster_id: str,

src/together/lib/cli/api/beta/clusters/list.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import click
44

55
from together import Together
6+
from together.lib.cli._track_cli import auto_track_command
67

78

89
@click.command()
@@ -12,6 +13,7 @@
1213
help="Output in JSON format",
1314
)
1415
@click.pass_context
16+
@auto_track_command("clusters list")
1517
def list(ctx: click.Context, json: bool) -> None:
1618
"""List clusters"""
1719
client: Together = ctx.obj

src/together/lib/cli/api/beta/clusters/list_regions.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from tabulate import tabulate
66

77
from together import Together
8+
from together.lib.cli._track_cli import auto_track_command
89
from together.lib.cli.api._utils import handle_api_errors
910

1011

@@ -16,6 +17,7 @@
1617
)
1718
@click.pass_context
1819
@handle_api_errors("Clusters")
20+
@auto_track_command("clusters list-regions")
1921
def list_regions(ctx: click.Context, json: bool) -> None:
2022
"""List regions"""
2123
client: Together = ctx.obj

src/together/lib/cli/api/beta/clusters/retrieve.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from rich import print
55

66
from together import Together
7+
from together.lib.cli._track_cli import auto_track_command
78
from together.lib.cli.api._utils import handle_api_errors
89

910

@@ -16,6 +17,7 @@
1617
)
1718
@click.pass_context
1819
@handle_api_errors("Clusters")
20+
@auto_track_command("clusters retrieve")
1921
def retrieve(ctx: click.Context, cluster_id: str, json: bool) -> None:
2022
"""Retrieve a cluster by ID"""
2123
client: Together = ctx.obj

src/together/lib/cli/api/beta/clusters/storage/create.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import click
44

55
from together import Together
6+
from together.lib.cli._track_cli import auto_track_command
67
from together.lib.cli.api._utils import handle_api_errors
78

89

@@ -31,6 +32,7 @@
3132
help="Output in JSON format",
3233
)
3334
@click.pass_context
35+
@auto_track_command("clusters storage create")
3436
@handle_api_errors("Clusters Storage")
3537
def create(ctx: click.Context, region: str, size_tib: int, volume_name: str, json: bool) -> None:
3638
"""Create a storage volume"""

0 commit comments

Comments
 (0)