Skip to content
Merged
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
8 changes: 6 additions & 2 deletions samcli/lib/telemetry/metric.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from samcli.lib.warnings.sam_cli_warning import TemplateWarningsChecker
from samcli.commands.exceptions import UserException
from samcli.lib.telemetry.cicd import CICDDetector, CICDPlatform
from samcli.lib.telemetry.project_metadata import get_git_remote_origin_url, get_project_name, get_initial_commit_hash
from samcli.commands._utils.experimental import get_all_experimental_statues
from .telemetry import Telemetry
from ..iac.cdk.utils import is_cdk_project
Expand Down Expand Up @@ -153,8 +154,11 @@ def wrapped(*args, **kwargs):
metric.add_data("debugFlagProvided", bool(ctx.debug))
metric.add_data("region", ctx.region or "")
metric.add_data("commandName", ctx.command_path) # Full command path. ex: sam local start-api
if metric_specific_attributes:
metric.add_data("metricSpecificAttributes", metric_specific_attributes)
# Project metadata metrics
metric_specific_attributes["gitOrigin"] = get_git_remote_origin_url()
metric_specific_attributes["projectName"] = get_project_name()
metric_specific_attributes["initialCommit"] = get_initial_commit_hash()
metric.add_data("metricSpecificAttributes", metric_specific_attributes)
# Metric about command's execution characteristics
metric.add_data("duration", duration_fn())
metric.add_data("exitReason", exit_reason)
Expand Down
110 changes: 110 additions & 0 deletions samcli/lib/telemetry/project_metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
"""
Creates and encrypts metadata regarding SAM CLI projects.
"""

from os import getcwd
from os.path import basename
import re
import subprocess
from typing import List, Optional
from uuid import uuid5, NAMESPACE_URL

from samcli.cli.global_config import GlobalConfig


def get_git_remote_origin_url() -> Optional[str]:
"""
Retrieve an encrypted version of the project's git remote origin url, if it exists.

Returns
-------
str | None
A UUID5 encrypted string of the git remote origin url, formatted such that the
encrypted value follows the pattern <hostname>/<owner>/<project_name>.git.
If telemetry is opted out of by the user, or the `.git` folder is not found
(the directory is not a git repository), returns None
"""
if not bool(GlobalConfig().telemetry_enabled):
return None

git_url = None
try:
runcmd = subprocess.run(
["git", "config", "--get", "remote.origin.url"], capture_output=True, shell=True, check=True, text=True
)
metadata = _parse_remote_origin_url(str(runcmd.stdout))
git_url = "/".join(metadata) + ".git" # Format to <hostname>/<owner>/<project_name>.git
except subprocess.CalledProcessError:
return None # Not a git repo

return _encrypt_value(git_url)


def get_project_name() -> Optional[str]:
"""
Retrieve an encrypted version of the project's name, as defined by the .git folder (or directory name if no .git).

Returns
-------
str | None
A UUID5 encrypted string of either the name of the project, or the name of the
current directory that the command is running in.
If telemetry is opted out of by the user, returns None
"""
if not bool(GlobalConfig().telemetry_enabled):
return None

project_name = ""
try:
runcmd = subprocess.run(
["git", "config", "--get", "remote.origin.url"], capture_output=True, shell=True, check=True, text=True
Comment thread
Leo10Gama marked this conversation as resolved.
)
project_name = _parse_remote_origin_url(str(runcmd.stdout))[2] # dir is git repo, get project name from URL
except subprocess.CalledProcessError:
project_name = basename(getcwd().replace("\\", "/")) # dir is not a git repo, get directory name

return _encrypt_value(project_name)


def get_initial_commit_hash() -> Optional[str]:
"""
Retrieve an encrypted version of the project's initial commit hash, if it exists.

Returns
-------
str | None
A UUID5 encrypted string of the git project's initial commit hash.
If telemetry is opted out of by the user, or the `.git` folder is not found
(the directory is not a git repository), returns None.
"""
if not bool(GlobalConfig().telemetry_enabled):
return None

metadata = None
try:
runcmd = subprocess.run(
["git", "rev-list", "--max-parents=0", "HEAD"], capture_output=True, shell=True, check=True, text=True
)
metadata = runcmd.stdout.strip()
except subprocess.CalledProcessError:
return None # Not a git repo

return _encrypt_value(metadata)


def _parse_remote_origin_url(url: str) -> List[str]:
"""
Parse a `git remote origin url` into its hostname, owner, and project name.

Returns
-------
List[str]
A list of 3 strings, with indeces corresponding to 0:hostname, 1:owner, 2:project_name
"""
pattern = re.compile(r"(?:https?://|git@)(?P<hostname>\S*)(?:/|:)(?P<owner>\S*)/(?P<project_name>\S*)\.git")
return [str(item) for item in pattern.findall(url)[0]]


def _encrypt_value(value: str) -> str:
"""Encrypt a string, and then return the encrypted value as a string."""
return str(uuid5(NAMESPACE_URL, value))
14 changes: 13 additions & 1 deletion tests/integration/telemetry/test_experimental_metric.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ def test_must_send_experimental_metrics_if_experimental_command(self):
"experimentalAccelerate": True,
"experimentalAll": False,
"experimentalEsbuild": False,
"gitOrigin": ANY,
"projectName": ANY,
"initialCommit": ANY,
},
"duration": ANY,
"exitReason": ANY,
Expand Down Expand Up @@ -107,6 +110,9 @@ def test_must_send_experimental_metrics_if_experimental_option(self):
"experimentalAccelerate": True,
"experimentalAll": True,
"experimentalEsbuild": True,
"gitOrigin": ANY,
"projectName": ANY,
"initialCommit": ANY,
},
"duration": ANY,
"exitReason": ANY,
Expand Down Expand Up @@ -164,7 +170,12 @@ def test_must_send_cdk_project_type_metrics(self):
"debugFlagProvided": ANY,
"region": ANY,
"commandName": ANY,
"metricSpecificAttributes": {"projectType": "CDK"},
"metricSpecificAttributes": {
"projectType": "CDK",
"gitOrigin": ANY,
"projectName": ANY,
"initialCommit": ANY,
},
"duration": ANY,
"exitReason": ANY,
"exitCode": ANY,
Expand Down Expand Up @@ -210,6 +221,7 @@ def test_must_send_not_experimental_metrics_if_not_experimental(self):
"debugFlagProvided": ANY,
"region": ANY,
"commandName": ANY,
"metricSpecificAttributes": ANY,
"duration": ANY,
"exitReason": ANY,
"exitCode": ANY,
Expand Down
1 change: 1 addition & 0 deletions tests/unit/lib/telemetry/test_metric.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ def real_fn():
"debugFlagProvided": False,
"region": "myregion",
"commandName": "fakesam local invoke",
"metricSpecificAttributes": ANY,
"duration": ANY,
"exitReason": "success",
"exitCode": 0,
Expand Down
111 changes: 111 additions & 0 deletions tests/unit/lib/telemetry/test_project_metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
"""
Module for testing the project_metadata.py methods.
"""

from subprocess import CompletedProcess, CalledProcessError
from uuid import uuid5, NAMESPACE_URL
from unittest.mock import patch, Mock
from unittest import TestCase

from parameterized import parameterized

from samcli.lib.telemetry.project_metadata import get_git_remote_origin_url, get_project_name, get_initial_commit_hash


class TestProjectMetadata(TestCase):
def setUp(self):
self.gc_mock = Mock()
self.global_config_patcher = patch("samcli.lib.telemetry.project_metadata.GlobalConfig", self.gc_mock)
self.global_config_patcher.start()
self.gc_mock.return_value.telemetry_enabled = True

def tearDown(self):
self.global_config_patcher.stop()

def test_return_none_when_telemetry_disabled(self):
self.gc_mock.return_value.telemetry_enabled = False

git_origin = get_git_remote_origin_url()
self.assertIsNone(git_origin)

project_name = get_project_name()
self.assertIsNone(project_name)

initial_commit = get_initial_commit_hash()
self.assertIsNone(initial_commit)

@parameterized.expand(
[
("https://github.com/aws/aws-sam-cli.git\n", "github.com/aws/aws-sam-cli.git"),
("http://github.com/aws/aws-sam-cli.git\n", "github.com/aws/aws-sam-cli.git"),
("git@github.com:aws/aws-sam-cli.git\n", "github.com/aws/aws-sam-cli.git"),
("https://github.com/aws/aws-cli.git\n", "github.com/aws/aws-cli.git"),
("http://not.a.real.site.com/somebody/my-project.git", "not.a.real.site.com/somebody/my-project.git"),
("git@not.github:person/my-project.git", "not.github/person/my-project.git"),
]
)
@patch("samcli.lib.telemetry.project_metadata.subprocess.run")
def test_retrieve_git_origin(self, origin, expected, sp_mock):
sp_mock.return_value = CompletedProcess(["git", "config", "--get", "remote.origin.url"], 0, stdout=origin)

git_origin = get_git_remote_origin_url()
self.assertEqual(git_origin, str(uuid5(NAMESPACE_URL, expected)))

@patch("samcli.lib.telemetry.project_metadata.subprocess.run")
def test_retrieve_git_origin_when_not_a_repo(self, sp_mock):
sp_mock.side_effect = CalledProcessError(128, ["git", "config", "--get", "remote.origin.url"])

git_origin = get_git_remote_origin_url()
self.assertIsNone(git_origin)

@parameterized.expand(
[
("https://github.com/aws/aws-sam-cli.git\n", "aws-sam-cli"),
("https://github.com/aws/aws-sam-cli.git\n", "aws-sam-cli"),
("git@github.com:aws/aws-sam-cli.git\n", "aws-sam-cli"),
("https://github.com/aws/aws-cli.git\n", "aws-cli"),
("http://not.a.real.site.com/somebody/my-project.git", "my-project"),
("git@not.github:person/my-project.git", "my-project"),
]
)
@patch("samcli.lib.telemetry.project_metadata.subprocess.run")
def test_retrieve_project_name_from_git(self, origin, expected, sp_mock):
sp_mock.return_value = CompletedProcess(["git", "config", "--get", "remote.origin.url"], 0, stdout=origin)

project_name = get_project_name()
self.assertEqual(project_name, str(uuid5(NAMESPACE_URL, expected)))

@parameterized.expand(
[
("C:/Users/aws/path/to/library/aws-sam-cli", "aws-sam-cli"),
("C:\\Users\\aws\\Windows\\path\\aws-sam-cli", "aws-sam-cli"),
("C:/", ""),
("C:\\", ""),
("E:/path/to/another/dir", "dir"),
("This/one/doesn't/start/with/a/letter", "letter"),
("/banana", "banana"),
("D:/one/more/just/to/be/safe", "safe"),
]
)
@patch("samcli.lib.telemetry.project_metadata.getcwd")
@patch("samcli.lib.telemetry.project_metadata.subprocess.run")
def test_retrieve_project_name_from_dir(self, cwd, expected, sp_mock, cwd_mock):
sp_mock.side_effect = CalledProcessError(128, ["git", "config", "--get", "remote.origin.url"])
cwd_mock.return_value = cwd

project_name = get_project_name()
self.assertEqual(project_name, str(uuid5(NAMESPACE_URL, expected)))

@parameterized.expand(
[
("0000000000000000000000000000000000000000"),
("0123456789abcdef0123456789abcdef01234567"),
("abababababababababababababababababababab"),
]
)
@patch("samcli.lib.telemetry.project_metadata.subprocess.run")
def test_retrieve_initial_commit(self, git_hash, sp_mock):
sp_mock.return_value = CompletedProcess(["git", "rev-list", "--max-parents=0", "HEAD"], 0, stdout=git_hash)

initial_commit = get_initial_commit_hash()
self.assertEqual(initial_commit, str(uuid5(NAMESPACE_URL, git_hash)))