Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion smart_tests/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from smart_tests.commands.subset import subset
from smart_tests.commands.update import update
from smart_tests.commands.verify import verify
from smart_tests.utils.tracking import send_command_tracking

cli = Group(name="cli", callback=Application)
cli.add_command(record)
Expand Down Expand Up @@ -59,7 +60,18 @@ def _load_test_runners():


def main():
cli.main()
argv = sys.argv[:]
exit_code = 0
try:
cli.main()
except SystemExit as e:
exit_code = e.code if isinstance(e.code, int) else 1
finally:
try:
send_command_tracking(argv=argv, exit_code=exit_code)
Copy link
Copy Markdown
Contributor

@psakthivel04 psakthivel04 May 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we invoke the CLI tracking asynchronously instead of blocking the execution?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is invoked when all the command is processed already, and the CLI is about to exit. Asynchronous execution here might not be needed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While it's true that tracking happens in the finally block after the command completes,
the HTTP request still blocks the CLI exit. This means every user waits an extra
50-200ms (or more with slow networks) for the tracking call to complete

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But since the CLI is about to exit, we have these options

  • Forking: But forking will add extra overhead and might not be worth
  • Daemon threads or asyncio process get killed the moment sys.exit() is called

But these are not helpful unless we change the shutdown process some how.

I will add a short timeout, because if the server is unreachable, the cli will hang, this is more of an issue.

except Exception:
pass
sys.exit(exit_code)


if __name__ == '__main__':
Expand Down
9 changes: 8 additions & 1 deletion smart_tests/utils/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,19 @@ class Command(Enum):
RECORD_TESTS = 'RECORD_TESTS'
RECORD_BUILD = 'RECORD_BUILD'
RECORD_SESSION = 'RECORD_SESSION'
RECORD_ATTACHMENT = 'RECORD_ATTACHMENT'
RECORD_DEPLOYMENT = 'RECORD_DEPLOYMENT'
SUBSET = 'SUBSET'
COMMIT = 'COMMIT'
DETECT_FLAKE = 'DETECT_FLAKE'
GATE = 'GATE'
UPDATE_ALIAS = 'UPDATE_ALIAS'
RECORD_DEPLOYMENT = 'RECORD_DEPLOYMENT'
INSPECT_MODEL = 'INSPECT_MODEL'
INSPECT_SUBSET = 'INSPECT_SUBSET'
STATS_TEST_SESSIONS = 'STATS_TEST_SESSIONS'
COMPARE_SUBSETS = 'COMPARE_SUBSETS'
GET_DOCS = 'GET_DOCS'
UNKNOWN = 'UNKNOWN'

# when you add a new constant here, the server also needs to get a new constant in cli_tracking.proto

Expand Down
13 changes: 13 additions & 0 deletions smart_tests/utils/env_keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
COMMIT_TIMEOUT = "SMART_TESTS_COMMIT_TIMEOUT"
SKIP_CERT_VERIFICATION = "SMART_TESTS_SKIP_CERT_VERIFICATION"
SESSION_DIR_KEY = "SMART_TESTS_SESSION_DIR"
CALLER_KEY = "SMART_TESTS_CALLER"

# Legacy token key for backward compatibility
LEGACY_TOKEN_KEY = "LAUNCHABLE_TOKEN"
Expand All @@ -17,3 +18,15 @@
def get_token():
"""Get token with backward compatibility for LAUNCHABLE_TOKEN."""
return os.getenv(TOKEN_KEY) or os.getenv(LEGACY_TOKEN_KEY)


def detect_ci_provider() -> str:
if os.environ.get("GITHUB_ACTIONS"):
return "github-actions"
if os.environ.get("JENKINS_URL"):
return "jenkins"
if os.environ.get("CIRCLECI"):
return "circleci"
if os.environ.get("CODEBUILD_BUILD_ID"):
return "codebuild"
return ""
112 changes: 87 additions & 25 deletions smart_tests/utils/tracking.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,74 @@
import os
from enum import Enum
from itertools import takewhile
from typing import Any, Dict, Union

from requests import Session

from smart_tests.app import Application
from smart_tests.utils.authentication import get_org_workspace
from smart_tests.utils.env_keys import CALLER_KEY, detect_ci_provider
from smart_tests.utils.http_client import _HttpClient, _join_paths
from smart_tests.version import __version__

from .commands import Command

# Map CLI subcommand tokens to Command enum values.
# Longer matches are tried first so "record build" matches before "record".
_COMMAND_MAP = {
Comment thread
ItIsUday marked this conversation as resolved.
("verify",): Command.VERIFY,
("record", "build"): Command.RECORD_BUILD,
("record", "session"): Command.RECORD_SESSION,
Comment thread
ItIsUday marked this conversation as resolved.
("record", "tests"): Command.RECORD_TESTS,
("record", "commit"): Command.COMMIT,
("record", "attachment"): Command.RECORD_ATTACHMENT,
("record", "deployment"): Command.RECORD_DEPLOYMENT,
("subset",): Command.SUBSET,
("detect-flakes",): Command.DETECT_FLAKE,
("gate",): Command.GATE,
("update", "alias"): Command.UPDATE_ALIAS,
("inspect", "model"): Command.INSPECT_MODEL,
("inspect", "subset"): Command.INSPECT_SUBSET,
("stats", "test_sessions"): Command.STATS_TEST_SESSIONS,
("compare", "subsets"): Command.COMPARE_SUBSETS,
("get", "docs"): Command.GET_DOCS,
}


def _detect_command(argv: list[str]) -> Command:
Comment thread
psakthivel04 marked this conversation as resolved.
"""Best-effort detection of the Command from argv. Returns UNKNOWN for typos."""
command_tokens = list(takewhile(lambda a: not a.startswith("-"), argv[1:]))

for tokens, command in sorted(_COMMAND_MAP.items(), key=lambda x: -len(x[0])):
if tuple(command_tokens[:len(tokens)]) == tokens:
return command
return Command.UNKNOWN


def send_command_tracking(argv: list[str], exit_code: int):
"""Send a single COMMAND_INVOCATION event with the full command string. Fire-and-forget."""
client = TrackingClient(_detect_command(argv))
metadata = {
"exitCode": str(exit_code),
}

raw_command = " ".join(argv)[:2000]

payload = client.construct_payload(
event_name=Tracking.Event.COMMAND_INVOCATION,
metadata=metadata,
raw_command=raw_command,
)

client.post_payload(payload=payload)


class Tracking:
# General events
class Event(Enum):
SHALLOW_CLONE = 'SHALLOW_CLONE' # this event is an example
PERFORMANCE = 'PERFORMANCE'
COMMAND_INVOCATION = 'COMMAND_INVOCATION'

# Error events
class ErrorEvent(Enum):
Expand Down Expand Up @@ -45,14 +98,8 @@ def send_event(
event_name: Tracking.Event,
metadata: Dict[str, Any] | None = None
):
org, workspace = get_org_workspace()
if metadata is None:
metadata = {}
metadata["organization"] = org or ""
metadata["workspace"] = workspace or ""
self._post_payload(
event_name=event_name,
metadata=metadata,
self.post_payload(
payload=self.construct_payload(event_name=event_name, metadata=metadata),
)

def send_error_event(
Expand All @@ -62,34 +109,49 @@ def send_error_event(
api: str = "",
metadata: Dict[str, Any] | None = None
):
org, workspace = get_org_workspace()
if metadata is None:
metadata = {}
metadata["stackTrace"] = stack_trace
metadata["organization"] = org or ""
metadata["workspace"] = workspace or ""
metadata["api"] = api
Comment on lines 114 to 115
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why did you leave stackTrace and api in this method instead of putting them in construct_payload method?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That was intentional because the stackTrace and api are visible to only send_error_event(). And that method can create a partial metadata dict with those values already. It is similar to other method like send_event() and send_command_tracking() where a partial metadata is generated with values under that specific method's scope.

self._post_payload(
event_name=event_name,
metadata=metadata,
)

def _post_payload(
payload = self.construct_payload(event_name=event_name, metadata=metadata)
self.post_payload(payload=payload)

def post_payload(
self,
event_name: Union[Tracking.Event, Tracking.ErrorEvent],
metadata: Dict[str, Any]
payload: dict,
):
payload = {
"command": self.command.value,
"eventName": event_name.value,
"cliVersion": __version__,
"metadata": metadata,
}
path = _join_paths(
'/intake',
'cli_tracking'
)
try:
self.http_client.request('post', payload=payload, path=path)
self.http_client.request('post', payload=payload, path=path, timeout=(2, 2))
except Exception:
pass

def construct_payload(
self,
event_name: Union[Tracking.Event, Tracking.ErrorEvent],
metadata: Dict[str, Any] | None = None,
raw_command: str | None = None
) -> dict:
org, workspace = get_org_workspace()

if metadata is None:
metadata = {}

metadata["organization"] = org or ""
metadata["workspace"] = workspace or ""
metadata["caller"] = os.environ.get(CALLER_KEY) or "cli"
metadata["ciProvider"] = detect_ci_provider()

payload = {
"command": self.command.value,
"eventName": event_name.value,
"cliVersion": __version__,
"metadata": metadata,
"rawCommand": raw_command,
}

return payload
7 changes: 6 additions & 1 deletion tests/utils/test_http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@
class HttpClientTest(TestCase):
@mock.patch.dict(
os.environ,
{"SMART_TESTS_ORGANIZATION": "launchableinc", "SMART_TESTS_WORKSPACE": "test"},
{
"SMART_TESTS_ORGANIZATION": "launchableinc",
"SMART_TESTS_WORKSPACE": "test",
"SMART_TESTS_TOKEN": "",
"LAUNCHABLE_TOKEN": "",
},
clear=True,
)
def test_header(self):
Expand Down
Loading
Loading