diff --git a/samcli/commands/build/build_context.py b/samcli/commands/build/build_context.py index 1a86f502e1..effacba583 100644 --- a/samcli/commands/build/build_context.py +++ b/samcli/commands/build/build_context.py @@ -11,6 +11,7 @@ from samcli.commands._utils.experimental import ExperimentalFlag, prompt_experimental from samcli.lib.providers.sam_api_provider import SamApiProvider +from samcli.lib.telemetry.event import EventTracker from samcli.lib.utils.packagetype import IMAGE from samcli.commands._utils.template import get_template_data @@ -271,6 +272,9 @@ def run(self): move_template(stack.location, output_template_path, modified_template) + for f in self.get_resources_to_build().functions: + EventTracker.track_event("BuildFunctionRuntime", f.runtime) + click.secho("\nBuild Succeeded", fg="green") # try to use relpath so the command is easier to understand, however, diff --git a/samcli/commands/sync/command.py b/samcli/commands/sync/command.py index 1201dd2f9c..e4d9d43383 100644 --- a/samcli/commands/sync/command.py +++ b/samcli/commands/sync/command.py @@ -28,6 +28,7 @@ ) from samcli.cli.cli_config_file import configuration_option, TomlProvider from samcli.commands._utils.click_mutex import ClickMutex +from samcli.lib.telemetry.event import EventTracker, track_long_event from samcli.commands.sync.sync_context import SyncContext from samcli.lib.utils.colors import Colored from samcli.lib.utils.version_checker import check_newer_version @@ -132,6 +133,7 @@ @capabilities_option(default=DEFAULT_CAPABILITIES) # pylint: disable=E1120 @pass_context @track_command +@track_long_event("SyncUsed", "Start", "SyncUsed", "End") @image_repository_validation @track_template_warnings([CodeDeployWarning.__name__, CodeDeployConditionWarning.__name__]) @check_newer_version @@ -236,6 +238,7 @@ def do_cli( build_dir = DEFAULT_BUILD_DIR_WITH_AUTO_DEPENDENCY_LAYER if dependency_layer else DEFAULT_BUILD_DIR LOG.debug("Using build directory as %s", build_dir) + EventTracker.track_event("UsedFeature", "Accelerate") with BuildContext( resource_identifier=None, diff --git a/samcli/lib/build/workflow_config.py b/samcli/lib/build/workflow_config.py index 3a006ca2e4..d6b1f55926 100644 --- a/samcli/lib/build/workflow_config.py +++ b/samcli/lib/build/workflow_config.py @@ -4,95 +4,24 @@ import os import logging -from collections import namedtuple from typing import Dict, List, Optional, Tuple, Union, cast -LOG = logging.getLogger(__name__) - -CONFIG = namedtuple( - "Capability", - ["language", "dependency_manager", "application_framework", "manifest_name", "executable_search_paths"], -) - -PYTHON_PIP_CONFIG = CONFIG( - language="python", - dependency_manager="pip", - application_framework=None, - manifest_name="requirements.txt", - executable_search_paths=None, -) - -NODEJS_NPM_CONFIG = CONFIG( - language="nodejs", - dependency_manager="npm", - application_framework=None, - manifest_name="package.json", - executable_search_paths=None, -) - -RUBY_BUNDLER_CONFIG = CONFIG( - language="ruby", - dependency_manager="bundler", - application_framework=None, - manifest_name="Gemfile", - executable_search_paths=None, -) - -JAVA_GRADLE_CONFIG = CONFIG( - language="java", - dependency_manager="gradle", - application_framework=None, - manifest_name="build.gradle", - executable_search_paths=None, -) - -JAVA_KOTLIN_GRADLE_CONFIG = CONFIG( - language="java", - dependency_manager="gradle", - application_framework=None, - manifest_name="build.gradle.kts", - executable_search_paths=None, +from samcli.lib.build.workflows import ( + CONFIG, + PYTHON_PIP_CONFIG, + NODEJS_NPM_CONFIG, + RUBY_BUNDLER_CONFIG, + JAVA_GRADLE_CONFIG, + JAVA_KOTLIN_GRADLE_CONFIG, + JAVA_MAVEN_CONFIG, + DOTNET_CLIPACKAGE_CONFIG, + GO_MOD_CONFIG, + PROVIDED_MAKE_CONFIG, + NODEJS_NPM_ESBUILD_CONFIG, ) +from samcli.lib.telemetry.event import EventTracker -JAVA_MAVEN_CONFIG = CONFIG( - language="java", - dependency_manager="maven", - application_framework=None, - manifest_name="pom.xml", - executable_search_paths=None, -) - -DOTNET_CLIPACKAGE_CONFIG = CONFIG( - language="dotnet", - dependency_manager="cli-package", - application_framework=None, - manifest_name=".csproj", - executable_search_paths=None, -) - -GO_MOD_CONFIG = CONFIG( - language="go", - dependency_manager="modules", - application_framework=None, - manifest_name="go.mod", - executable_search_paths=None, -) - -PROVIDED_MAKE_CONFIG = CONFIG( - language="provided", - dependency_manager=None, - application_framework=None, - manifest_name="Makefile", - executable_search_paths=None, -) - -NODEJS_NPM_ESBUILD_CONFIG = CONFIG( - language="nodejs", - dependency_manager="npm-esbuild", - application_framework=None, - manifest_name="package.json", - executable_search_paths=None, -) +LOG = logging.getLogger(__name__) class UnsupportedRuntimeException(Exception): @@ -278,6 +207,9 @@ def get_workflow_config( # Identify workflow configuration from the workflow selector. config = cast(WorkFlowSelector, selector).get_config(code_dir, project_dir) + + EventTracker.track_event("BuildWorkflowUsed", f"{config.language}-{config.dependency_manager}") + return config except ValueError as ex: raise UnsupportedRuntimeException( diff --git a/samcli/lib/build/workflows.py b/samcli/lib/build/workflows.py new file mode 100644 index 0000000000..a413f696c1 --- /dev/null +++ b/samcli/lib/build/workflows.py @@ -0,0 +1,102 @@ +"""Module for storing information about existing workflows.""" + +from collections import namedtuple +from typing import List + +CONFIG = namedtuple( + "Capability", + ["language", "dependency_manager", "application_framework", "manifest_name", "executable_search_paths"], +) + +PYTHON_PIP_CONFIG = CONFIG( + language="python", + dependency_manager="pip", + application_framework=None, + manifest_name="requirements.txt", + executable_search_paths=None, +) + +NODEJS_NPM_CONFIG = CONFIG( + language="nodejs", + dependency_manager="npm", + application_framework=None, + manifest_name="package.json", + executable_search_paths=None, +) + +RUBY_BUNDLER_CONFIG = CONFIG( + language="ruby", + dependency_manager="bundler", + application_framework=None, + manifest_name="Gemfile", + executable_search_paths=None, +) + +JAVA_GRADLE_CONFIG = CONFIG( + language="java", + dependency_manager="gradle", + application_framework=None, + manifest_name="build.gradle", + executable_search_paths=None, +) + +JAVA_KOTLIN_GRADLE_CONFIG = CONFIG( + language="java", + dependency_manager="gradle", + application_framework=None, + manifest_name="build.gradle.kts", + executable_search_paths=None, +) + +JAVA_MAVEN_CONFIG = CONFIG( + language="java", + dependency_manager="maven", + application_framework=None, + manifest_name="pom.xml", + executable_search_paths=None, +) + +DOTNET_CLIPACKAGE_CONFIG = CONFIG( + language="dotnet", + dependency_manager="cli-package", + application_framework=None, + manifest_name=".csproj", + executable_search_paths=None, +) + +GO_MOD_CONFIG = CONFIG( + language="go", + dependency_manager="modules", + application_framework=None, + manifest_name="go.mod", + executable_search_paths=None, +) + +PROVIDED_MAKE_CONFIG = CONFIG( + language="provided", + dependency_manager=None, + application_framework=None, + manifest_name="Makefile", + executable_search_paths=None, +) + +NODEJS_NPM_ESBUILD_CONFIG = CONFIG( + language="nodejs", + dependency_manager="npm-esbuild", + application_framework=None, + manifest_name="package.json", + executable_search_paths=None, +) + +ALL_CONFIGS: List[CONFIG] = [ + PYTHON_PIP_CONFIG, + NODEJS_NPM_CONFIG, + RUBY_BUNDLER_CONFIG, + JAVA_GRADLE_CONFIG, + JAVA_KOTLIN_GRADLE_CONFIG, + JAVA_MAVEN_CONFIG, + DOTNET_CLIPACKAGE_CONFIG, + GO_MOD_CONFIG, + PROVIDED_MAKE_CONFIG, + NODEJS_NPM_ESBUILD_CONFIG, +] diff --git a/samcli/lib/sync/sync_flow_executor.py b/samcli/lib/sync/sync_flow_executor.py index 0cba6305cd..082a57c70d 100644 --- a/samcli/lib/sync/sync_flow_executor.py +++ b/samcli/lib/sync/sync_flow_executor.py @@ -10,6 +10,7 @@ from concurrent.futures import ThreadPoolExecutor, Future from botocore.exceptions import ClientError +from samcli.lib.telemetry.event import EventName, EventTracker, EventType from samcli.lib.utils.colors import Colored from samcli.lib.providers.exceptions import MissingLocalDefinition @@ -331,7 +332,13 @@ def _sync_flow_execute_wrapper(sync_flow: SyncFlow) -> SyncFlowResult: SyncFlowException """ dependent_sync_flows = [] + sync_types = EventType.get_accepted_values(EventName.SYNC_FLOW_START) + sync_type: Optional[str] = type(sync_flow).__name__ + if sync_type not in sync_types: + sync_type = None try: + if sync_type: + EventTracker.track_event("SyncFlowStart", sync_type) dependent_sync_flows = sync_flow.execute() except ClientError as e: if e.response.get("Error", dict()).get("Code", "") == "ResourceNotFoundException": @@ -339,4 +346,7 @@ def _sync_flow_execute_wrapper(sync_flow: SyncFlow) -> SyncFlowResult: raise SyncFlowException(sync_flow, e) from e except Exception as e: raise SyncFlowException(sync_flow, e) from e + finally: + if sync_type: + EventTracker.track_event("SyncFlowEnd", sync_type) return SyncFlowResult(sync_flow=sync_flow, dependent_sync_flows=dependent_sync_flows) diff --git a/samcli/lib/telemetry/event.py b/samcli/lib/telemetry/event.py new file mode 100644 index 0000000000..f04a0aaf26 --- /dev/null +++ b/samcli/lib/telemetry/event.py @@ -0,0 +1,297 @@ +""" +Represents Events and their values. +""" + +from datetime import datetime +from enum import Enum +import logging +import threading +from typing import List, Optional + +from samcli.cli.context import Context +from samcli.lib.build.workflows import ALL_CONFIGS +from samcli.lib.telemetry.telemetry import Telemetry +from samcli.local.common.runtime_template import INIT_RUNTIMES + + +LOG = logging.getLogger(__name__) + + +class EventName(Enum): + """Enum for the names of available events to track.""" + + USED_FEATURE = "UsedFeature" + BUILD_FUNCTION_RUNTIME = "BuildFunctionRuntime" + SYNC_USED = "SyncUsed" + SYNC_FLOW_START = "SyncFlowStart" + SYNC_FLOW_END = "SyncFlowEnd" + BUILD_WORKFLOW_USED = "BuildWorkflowUsed" + + +class EventType: + """Class for Events and the types of values they may have.""" + + _SYNC_FLOWS = [ + "AliasVersionSyncFlow", + "AutoDependencyLayerSyncFlow", + "AutoDependencyLayerParentSyncFlow", + "FunctionSyncFlow", + "FunctionLayerReferenceSync", + "GenericApiSyncFlow", + "HttpApiSyncFlow", + "ImageFunctionSyncFlow", + "LayerSyncFlow", + "RestApiSyncFlow", + "StepFunctionsSyncFlow", + "ZipFunctionSyncFlow", + ] + _WORKFLOWS = [f"{config.language}-{config.dependency_manager}" for config in ALL_CONFIGS] + + _event_values = { # Contains allowable values for Events + EventName.USED_FEATURE: [ + "Accelerate", + "CDK", + ], + EventName.BUILD_FUNCTION_RUNTIME: INIT_RUNTIMES, + EventName.SYNC_USED: [ + "Start", + "End", + ], + EventName.SYNC_FLOW_START: _SYNC_FLOWS, + EventName.SYNC_FLOW_END: _SYNC_FLOWS, + EventName.BUILD_WORKFLOW_USED: _WORKFLOWS, + } + + @staticmethod + def get_accepted_values(event_name: EventName) -> List[str]: + """Get all acceptable values for a given Event name.""" + if event_name not in EventType._event_values: + return [] + return EventType._event_values[event_name] + + +class Event: + """Class to represent Events that occur in SAM CLI.""" + + event_name: EventName + event_value: str # Validated by EventType.get_accepted_values to never be an arbitrary string + thread_id = threading.get_ident() # The thread ID; used to group Events from the same command run + time_stamp: str + + def __init__(self, event_name: str, event_value: str): + Event._verify_event(event_name, event_value) + self.event_name = EventName(event_name) + self.event_value = event_value + self.time_stamp = str(datetime.utcnow())[:-3] # format microseconds from 6 -> 3 figures to allow SQL casting + + def __eq__(self, other): + return self.event_name == other.event_name and self.event_value == other.event_value + + def __repr__(self): + return ( + f"Event(event_name={self.event_name.value}, " + f"event_value={self.event_value}, " + f"thread_id={self.thread_id}, " + f"time_stamp={self.time_stamp})" + ) + + def to_json(self): + return { + "event_name": self.event_name.value, + "event_value": self.event_value, + "thread_id": self.thread_id, + "time_stamp": self.time_stamp, + } + + @staticmethod + def _verify_event(event_name: str, event_value: str) -> None: + """Raise an EventCreationError if either the event name or value is not valid.""" + if event_name not in Event._get_event_names(): + raise EventCreationError(f"Event '{event_name}' does not exist.") + if event_value not in EventType.get_accepted_values(EventName(event_name)): + raise EventCreationError(f"Event '{event_name}' does not accept value '{event_value}'.") + + @staticmethod + def _get_event_names() -> List[str]: + """Retrieves a list of all valid event names.""" + return [event.value for event in EventName] + + +class EventTracker: + """Class to track and recreate Events as they occur.""" + + _events: List[Event] = [] + _event_lock = threading.Lock() + _session_id: Optional[str] = None + + MAX_EVENTS: int = 50 # Maximum number of events to store before sending + + @staticmethod + def track_event(event_name: str, event_value: str): + """Method to track an event where and when it occurs. + + Place this method in the codepath of the event that you would + like to track. For instance, if you would like to track when + FeatureX is used, append this method to the end of that function. + + Parameters + ---------- + event_name: str + The name of the Event. Must be a valid EventName value, or an + EventCreationError will be thrown. + event_value: str + The value of the Event. Must be a valid EventType value for the + passed event_name, or an EventCreationError will be thrown. + + Examples + -------- + >>> def feature_x(...): + # do things + EventTracker.track_event("UsedFeature", "FeatureX") + + >>> def feature_y(...) -> Any: + # do things + EventTracker.track_event("UsedFeature", "FeatureY") + return some_value + """ + try: + should_send: bool = False + with EventTracker._event_lock: + EventTracker._events.append(Event(event_name, event_value)) + # Get the session ID (needed for multithreading sending) + if not EventTracker._session_id: + try: + ctx = Context.get_current_context() + if ctx: + EventTracker._session_id = ctx.session_id + except RuntimeError: + LOG.debug("EventTracker: Unable to obtain session ID") + if len(EventTracker._events) >= EventTracker.MAX_EVENTS: + should_send = True + if should_send: + EventTracker.send_events() + except EventCreationError as e: + LOG.debug("Error occurred while trying to track an event: %s", e) + + @staticmethod + def get_tracked_events() -> List[Event]: + """Retrieve a list of all currently tracked Events.""" + with EventTracker._event_lock: + return EventTracker._events + + @staticmethod + def clear_trackers(): + """Clear the current list of tracked Events before the next session.""" + with EventTracker._event_lock: + EventTracker._events = [] + + @staticmethod + def send_events() -> threading.Thread: + """Call a thread to send the current list of Events via Telemetry.""" + send_thread = threading.Thread(target=EventTracker._send_events_in_thread) + send_thread.start() + return send_thread + + @staticmethod + def _send_events_in_thread(): + """Send the current list of Events via Telemetry.""" + from samcli.lib.telemetry.metric import Metric # pylint: disable=cyclic-import + + msa = {} + + with EventTracker._event_lock: + if not EventTracker._events: # Don't do anything if there are no events to send + return + + msa["events"] = [e.to_json() for e in EventTracker._events] + EventTracker._events = [] # Manual clear_trackers() since we're within the lock + + telemetry = Telemetry() + metric = Metric("events") + metric.add_data("sessionId", EventTracker._session_id) + metric.add_data("metricSpecificAttributes", msa) + telemetry.emit(metric) + + +def track_long_event(start_event_name: str, start_event_value: str, end_event_name: str, end_event_value: str): + """Decorator for tracking events that occur at start and end of a function. + + The decorator tracks two Events total, where the first Event occurs + at the start of the decorated function's execution (prior to its + first line) and the second Event occurs after the function has ended + (after the final line of the function has executed). + If this decorator is being placed in a function that also contains the + `track_command` decorator, ensure that this decorator is placed BELOW + `track_command`. Otherwise, the current list of Events will be sent + before the end_event will be added, resulting in an additional 'events' + metric with only that single Event. + + Parameters + ---------- + start_event_name: str + The name of the Event that is executed at the start of the + decorated function's execution. Must be a valid EventName + value or the decorator will not run. + start_event_value: str + The value of the Event that is executed at the start of the + decorated function's execution. Must be a valid EventType + value for the passed `start_event_name` or the decorator + will not run. + end_event_name: str + The name of the Event that is executed at the end of the + decorated function's execution. Must be a valid EventName + value or the decorator will not run. + end_event_value: str + The value of the Event that is executed at the end of the + decorated function's execution. Must be a valid EventType + value for the passed `end_event_name` or the decorator + will not run. + + Examples + -------- + >>> @track_long_event("FuncStart", "Func1", "FuncEnd", "Func1") + def func1(...): + # do things + + >>> @track_long_event("FuncStart", "Func2", "FuncEnd", "Func2") + def func2(...): + # do things + """ + should_track = True + try: + # Check that passed values are valid Events + Event(start_event_name, start_event_value) + Event(end_event_name, end_event_value) + except EventCreationError as e: + LOG.debug("Error occurred while trying to track an event: %s\nDecorator not run.", e) + should_track = False + + def decorator_for_events(func): + """The actual decorator""" + + def wrapped(*args, **kwargs): + # Track starting event + if should_track: + EventTracker.track_event(start_event_name, start_event_value) + exception = None + # Run the function + try: + return_value = func(*args, **kwargs) + except Exception as e: + exception = e + # Track ending event + if should_track: + EventTracker.track_event(end_event_name, end_event_value) + EventTracker.send_events() # Ensure Events are sent at the end of execution + if exception: + raise exception + + return return_value + + return wrapped + + return decorator_for_events + + +class EventCreationError(Exception): + """Exception called when an Event is not properly created.""" diff --git a/samcli/lib/telemetry/metric.py b/samcli/lib/telemetry/metric.py index f6336e2ddd..028eafd8df 100644 --- a/samcli/lib/telemetry/metric.py +++ b/samcli/lib/telemetry/metric.py @@ -17,6 +17,8 @@ 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.event import EventTracker +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 @@ -144,6 +146,8 @@ def wrapped(*args, **kwargs): try: template_dict = ctx.template_dict project_type = ProjectTypes.CDK.value if is_cdk_project(template_dict) else ProjectTypes.CFN.value + if project_type == ProjectTypes.CDK.value: + EventTracker.track_event("UsedFeature", "CDK") metric_specific_attributes["projectType"] = project_type except AttributeError: LOG.debug("Template is not provided in context, skip adding project type metric") @@ -153,12 +157,16 @@ 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) metric.add_data("exitCode", exit_code) + EventTracker.send_events() # Sends Event metrics to Telemetry before commandRun metrics telemetry.emit(metric) except RuntimeError: LOG.debug("Unable to find Click Context for getting session_id.") diff --git a/samcli/lib/telemetry/project_metadata.py b/samcli/lib/telemetry/project_metadata.py new file mode 100644 index 0000000000..c825db9022 --- /dev/null +++ b/samcli/lib/telemetry/project_metadata.py @@ -0,0 +1,111 @@ +""" +Creates and encrypts metadata regarding SAM CLI projects. +""" + +import hashlib +from os import getcwd +import re +import subprocess +from typing import List, Optional + +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 SHA256 hexdigest string of the git remote origin url, formatted such that the + encrypted value follows the pattern //.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 //.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 SHA256 hexdigest string of either the name of the project, or the name of the + current working 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 + ) + project_name = _parse_remote_origin_url(str(runcmd.stdout))[2] # dir is git repo, get project name from URL + except subprocess.CalledProcessError: + project_name = 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 SHA256 hexdigest 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\S*)(?:/|:)(?P\S*)/(?P\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 byte string.""" + h = hashlib.sha256() + h.update(value.encode("utf-8")) + return h.hexdigest() diff --git a/tests/integration/telemetry/test_experimental_metric.py b/tests/integration/telemetry/test_experimental_metric.py index d9a829054d..977e65053f 100644 --- a/tests/integration/telemetry/test_experimental_metric.py +++ b/tests/integration/telemetry/test_experimental_metric.py @@ -60,6 +60,9 @@ def test_must_send_experimental_metrics_if_experimental_command(self): "metricSpecificAttributes": { "experimentalAll": False, "experimentalEsbuild": False, + "gitOrigin": ANY, + "projectName": ANY, + "initialCommit": ANY, }, "duration": ANY, "exitReason": ANY, @@ -114,6 +117,9 @@ def test_must_send_experimental_metrics_if_experimental_option(self): "metricSpecificAttributes": { "experimentalAll": True, "experimentalEsbuild": True, + "gitOrigin": ANY, + "projectName": ANY, + "initialCommit": ANY, }, "duration": ANY, "exitReason": ANY, @@ -151,8 +157,11 @@ def test_must_send_cdk_project_type_metrics(self): process.communicate() all_requests = server.get_all_requests() - self.assertEqual(1, len(all_requests), "Command run metric must be sent") + self.assertGreaterEqual(len(all_requests), 1, "Command run metric must be sent") request = all_requests[0] + for req in all_requests: + if "commandRun" in req["data"]["metrics"][0]: + request = req # We're only testing the commandRun metric self.assertIn("Content-Type", request["headers"]) self.assertEqual(request["headers"]["Content-Type"], "application/json") @@ -171,7 +180,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, @@ -217,6 +231,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, diff --git a/tests/unit/commands/buildcmd/test_build_context.py b/tests/unit/commands/buildcmd/test_build_context.py index 3590f25709..9f76ebfbc8 100644 --- a/tests/unit/commands/buildcmd/test_build_context.py +++ b/tests/unit/commands/buildcmd/test_build_context.py @@ -1,6 +1,6 @@ import os from unittest import TestCase -from unittest.mock import patch, Mock, ANY, call +from unittest.mock import MagicMock, patch, Mock, ANY, call from parameterized import parameterized @@ -669,7 +669,7 @@ def test_run_sync_build_context( root_stack.stack_path: "./build_dir/template.yaml", child_stack.stack_path: "./build_dir/abcd/template.yaml", } - resources_mock.return_value = Mock() + resources_mock.return_value = MagicMock() builder_mock = ApplicationBuilderMock.return_value = Mock() artifacts = "artifacts" diff --git a/tests/unit/lib/build_module/test_app_builder.py b/tests/unit/lib/build_module/test_app_builder.py index 6423158384..0b07b5c79a 100644 --- a/tests/unit/lib/build_module/test_app_builder.py +++ b/tests/unit/lib/build_module/test_app_builder.py @@ -25,6 +25,7 @@ DockerConnectionError, ) from samcli.commands.local.cli_common.user_exceptions import InvalidFunctionPropertyType +from samcli.lib.telemetry.event import EventName, EventTracker from samcli.lib.utils.architecture import X86_64, ARM64 from samcli.lib.utils.packagetype import IMAGE, ZIP from samcli.lib.utils.stream_writer import StreamWriter @@ -1520,13 +1521,20 @@ def setUp(self): Mock(), "/build/dir", "/base/dir", "/cache/dir", mode="mode", stream_writer=StreamWriter(sys.stderr) ) + def tearDown(self): + EventTracker.clear_trackers() + @parameterized.expand([([],), (["ExpFlag1", "ExpFlag2"],)]) + @patch("samcli.lib.telemetry.event.EventType.get_accepted_values") @patch("samcli.lib.build.app_builder.LambdaBuilder") @patch("samcli.lib.build.app_builder.get_enabled_experimental_flags") - def test_must_use_lambda_builder(self, experimental_flags, experimental_flags_mock, lambda_builder_mock): + def test_must_use_lambda_builder( + self, experimental_flags, experimental_flags_mock, lambda_builder_mock, event_mock + ): experimental_flags_mock.return_value = experimental_flags config_mock = Mock() builder_instance_mock = lambda_builder_mock.return_value = Mock() + event_mock.return_value = ["runtime"] result = self.builder._build_function_in_process( config_mock, @@ -1589,10 +1597,14 @@ def test_must_raise_on_error(self, lambda_builder_mock): True, ) + @patch("samcli.lib.telemetry.event.EventType.get_accepted_values") @patch("samcli.lib.build.app_builder.LambdaBuilder") @patch("samcli.lib.build.app_builder.get_enabled_experimental_flags") - def test_building_with_experimental_flags(self, get_enabled_experimental_flags_mock, lambda_builder_mock): + def test_building_with_experimental_flags( + self, get_enabled_experimental_flags_mock, lambda_builder_mock, event_mock + ): get_enabled_experimental_flags_mock.return_value = ["A", "B", "C"] + event_mock.return_value = ["runtime"] config_mock = Mock() self.builder._build_function_in_process( config_mock, @@ -1644,11 +1656,18 @@ def setUp(self): ) self.builder._parse_builder_response = Mock() + def tearDown(self): + EventTracker.clear_trackers() + + @patch("samcli.lib.telemetry.event.EventType.get_accepted_values") @patch("samcli.lib.build.app_builder.LambdaBuildContainer") @patch("samcli.lib.build.app_builder.lambda_builders_protocol_version") @patch("samcli.lib.build.app_builder.LOG") @patch("samcli.lib.build.app_builder.osutils") - def test_must_build_in_container(self, osutils_mock, LOGMock, protocol_version_mock, LambdaBuildContainerMock): + def test_must_build_in_container( + self, osutils_mock, LOGMock, protocol_version_mock, LambdaBuildContainerMock, event_mock + ): + event_mock.return_value = "runtime" config = Mock() log_level = LOGMock.getEffectiveLevel.return_value = "foo" stdout_data = "container stdout response data" diff --git a/tests/unit/lib/build_module/test_workflow_config.py b/tests/unit/lib/build_module/test_workflow_config.py index 52549a3718..e35d6c05fe 100644 --- a/tests/unit/lib/build_module/test_workflow_config.py +++ b/tests/unit/lib/build_module/test_workflow_config.py @@ -7,12 +7,14 @@ UnsupportedRuntimeException, UnsupportedBuilderException, ) +from samcli.lib.telemetry.event import Event, EventTracker class Test_get_workflow_config(TestCase): def setUp(self): self.code_dir = "" self.project_dir = "" + EventTracker.clear_trackers() @parameterized.expand([("python3.6",), ("python3.7",), ("python3.8",)]) def test_must_work_for_python(self, runtime): @@ -23,6 +25,8 @@ def test_must_work_for_python(self, runtime): self.assertEqual(result.application_framework, None) self.assertEqual(result.manifest_name, "requirements.txt") self.assertIsNone(result.executable_search_paths) + self.assertEqual(len(EventTracker.get_tracked_events()), 1) + self.assertIn(Event("BuildWorkflowUsed", "python-pip"), EventTracker.get_tracked_events()) @parameterized.expand([("nodejs12.x",), ("nodejs14.x",), ("nodejs16.x",)]) def test_must_work_for_nodejs(self, runtime): @@ -33,6 +37,8 @@ def test_must_work_for_nodejs(self, runtime): self.assertEqual(result.application_framework, None) self.assertEqual(result.manifest_name, "package.json") self.assertIsNone(result.executable_search_paths) + self.assertEqual(len(EventTracker.get_tracked_events()), 1) + self.assertIn(Event("BuildWorkflowUsed", "nodejs-npm"), EventTracker.get_tracked_events()) @parameterized.expand([("provided",)]) def test_must_work_for_provided(self, runtime): @@ -42,6 +48,8 @@ def test_must_work_for_provided(self, runtime): self.assertEqual(result.application_framework, None) self.assertEqual(result.manifest_name, "Makefile") self.assertIsNone(result.executable_search_paths) + self.assertEqual(len(EventTracker.get_tracked_events()), 1) + self.assertIn(Event("BuildWorkflowUsed", "provided-None"), EventTracker.get_tracked_events()) @parameterized.expand([("provided",)]) def test_must_work_for_provided_with_no_specified_workflow(self, runtime): @@ -52,6 +60,8 @@ def test_must_work_for_provided_with_no_specified_workflow(self, runtime): self.assertEqual(result.application_framework, None) self.assertEqual(result.manifest_name, "Makefile") self.assertIsNone(result.executable_search_paths) + self.assertEqual(len(EventTracker.get_tracked_events()), 1) + self.assertIn(Event("BuildWorkflowUsed", "provided-None"), EventTracker.get_tracked_events()) @parameterized.expand([("provided",)]) def test_raise_exception_for_bad_specified_workflow(self, runtime): @@ -66,6 +76,8 @@ def test_must_work_for_ruby(self, runtime): self.assertEqual(result.application_framework, None) self.assertEqual(result.manifest_name, "Gemfile") self.assertIsNone(result.executable_search_paths) + self.assertEqual(len(EventTracker.get_tracked_events()), 1) + self.assertIn(Event("BuildWorkflowUsed", "ruby-bundler"), EventTracker.get_tracked_events()) @parameterized.expand( [("java8", "build.gradle", "gradle"), ("java8", "build.gradle.kts", "gradle"), ("java8", "pom.xml", "maven")] @@ -80,11 +92,14 @@ def test_must_work_for_java(self, runtime, build_file, dep_manager, os_mock): self.assertEqual(result.dependency_manager, dep_manager) self.assertEqual(result.application_framework, None) self.assertEqual(result.manifest_name, build_file) + self.assertEqual(len(EventTracker.get_tracked_events()), 1) if dep_manager == "gradle": self.assertEqual(result.executable_search_paths, [self.code_dir, self.project_dir]) + self.assertIn(Event("BuildWorkflowUsed", "java-gradle"), EventTracker.get_tracked_events()) else: self.assertIsNone(result.executable_search_paths) + self.assertIn(Event("BuildWorkflowUsed", "java-maven"), EventTracker.get_tracked_events()) def test_must_get_workflow_for_esbuild(self): runtime = "nodejs12.x" @@ -94,6 +109,8 @@ def test_must_get_workflow_for_esbuild(self): self.assertEqual(result.application_framework, None) self.assertEqual(result.manifest_name, "package.json") self.assertIsNone(result.executable_search_paths) + self.assertEqual(len(EventTracker.get_tracked_events()), 1) + self.assertIn(Event("BuildWorkflowUsed", "nodejs-npm-esbuild"), EventTracker.get_tracked_events()) @parameterized.expand([("java8", "unknown.manifest")]) @patch("samcli.lib.build.workflow_config.os") diff --git a/tests/unit/lib/telemetry/test_event.py b/tests/unit/lib/telemetry/test_event.py new file mode 100644 index 0000000000..f566a68554 --- /dev/null +++ b/tests/unit/lib/telemetry/test_event.py @@ -0,0 +1,244 @@ +""" +Module for testing the event.py methods and classes. +""" + +from enum import Enum +import threading +from typing import List, Tuple +from unittest import TestCase +from unittest.mock import ANY, Mock, patch + +from samcli.lib.telemetry.event import Event, EventCreationError, EventTracker, track_long_event + + +class DummyEventName(Enum): + TEST_ONE = "TestOne" + TEST_TWO = "TestTwo" + TEST_THREE = "TestThree" + + +class TestEventCreation(TestCase): + @patch("samcli.lib.telemetry.event.Event._verify_event") + @patch("samcli.lib.telemetry.event.EventType") + @patch("samcli.lib.telemetry.event.EventName") + def test_create_event_exists(self, name_mock, type_mock, verify_mock): + name_mock.return_value = Mock(value="TestOne") + type_mock.get_accepted_values.return_value = ["value1", "value2"] + verify_mock.return_value = None + + test_event = Event("TestOne", "value1") + + name_mock.assert_called_once() + self.assertEqual(test_event.event_name.value, "TestOne") + self.assertEqual(test_event.event_value, "value1") + self.assertEqual(test_event.thread_id, threading.get_ident()) # Should be on the same thread + + @patch("samcli.lib.telemetry.event.EventType") + @patch("samcli.lib.telemetry.event.EventName") + @patch("samcli.lib.telemetry.event.Event._get_event_names") + def test_create_event_value_doesnt_exist(self, name_getter_mock, name_mock, type_mock): + name_getter_mock.return_value = ["TestOne"] + name_mock.return_value = Mock(value="TestOne") + type_mock.get_accepted_values.return_value = ["value1", "value2"] + + with self.assertRaises(EventCreationError) as e: + Event("TestOne", "value3") + + self.assertEqual(e.exception.args[0], "Event 'TestOne' does not accept value 'value3'.") + + def test_create_event_name_doesnt_exist(self): + with self.assertRaises(EventCreationError) as e: + Event("SomeEventThatDoesn'tExist", "value1") + + self.assertEqual(e.exception.args[0], "Event 'SomeEventThatDoesn'tExist' does not exist.") + + @patch("samcli.lib.telemetry.event.Event._verify_event") + @patch("samcli.lib.telemetry.event.EventType") + @patch("samcli.lib.telemetry.event.EventName") + def test_event_to_json(self, name_mock, type_mock, verify_mock): + name_mock.return_value = Mock(value="Testing") + type_mock.get_accepted_values.return_value = ["value1"] + verify_mock.return_value = None + + test_event = Event("Testing", "value1") + + self.assertEqual( + test_event.to_json(), + {"event_name": "Testing", "event_value": "value1", "thread_id": threading.get_ident(), "time_stamp": ANY}, + ) + + +class TestEventTracker(TestCase): + def setUp(self): + EventTracker.clear_trackers() + + @patch("samcli.lib.telemetry.event.EventTracker._event_lock") + @patch("samcli.lib.telemetry.event.Event") + def test_track_event(self, event_mock, lock_mock): + lock_mock.__enter__ = Mock() + lock_mock.__exit__ = Mock() + + # Test that an event can be tracked + dummy_event = Mock(event_name="Test", event_value="SomeValue", thread_id=threading.get_ident(), timestamp=ANY) + event_mock.return_value = dummy_event + + EventTracker.track_event("Test", "SomeValue") + + self.assertEqual(len(EventTracker._events), 1) + self.assertEqual(EventTracker._events[0], dummy_event) + lock_mock.__enter__.assert_called() # Lock should have been accessed + lock_mock.__exit__.assert_called() + lock_mock.__enter__.reset_mock() + lock_mock.__exit__.reset_mock() + + # Test that the Event list will be cleared + EventTracker.clear_trackers() + + self.assertEqual(len(EventTracker._events), 0) + lock_mock.__enter__.assert_called() # Lock should have been accessed + lock_mock.__exit__.assert_called() + + @patch("samcli.lib.telemetry.event.Telemetry") + def test_events_get_sent(self, telemetry_mock): + # Create fake emit to capture tracked events + dummy_telemetry = Mock() + emitted_events = [] + mock_emit = lambda x: emitted_events.append(x) + dummy_telemetry.emit.return_value = None + dummy_telemetry.emit.side_effect = mock_emit + telemetry_mock.return_value = dummy_telemetry + + # Verify that no events are sent if tracker is empty + # Note we are using the in-line version of the method, as the regular send_events will + # simply call this method in a new thread + EventTracker._send_events_in_thread() + + self.assertEqual(emitted_events, []) # No events should have been collected + dummy_telemetry.emit.assert_not_called() # Nothing should have been sent (empty list) + + # Verify that events get sent when they exist in tracker + dummy_event = Mock( + event_name=Mock(value="Test"), event_value="SomeValue", thread_id=threading.get_ident(), time_stamp=ANY + ) + dummy_event.to_json.return_value = Event.to_json(dummy_event) + EventTracker._events.append(dummy_event) + + EventTracker._send_events_in_thread() + + dummy_telemetry.emit.assert_called() + self.assertEqual(len(emitted_events), 1) # The list of metrics (1) is copied into emitted_events + metric_data = emitted_events[0].get_data() + expected_data = { + "requestId": ANY, + "installationId": ANY, + "sessionId": ANY, + "executionEnvironment": ANY, + "ci": ANY, + "pyversion": ANY, + "samcliVersion": ANY, + "metricSpecificAttributes": { + "events": [ + { + "event_name": "Test", + "event_value": "SomeValue", + "thread_id": ANY, + "time_stamp": ANY, + } + ] + }, + } + self.assertEqual(len(metric_data["metricSpecificAttributes"]["events"]), 1) # There is one event captured + self.assertEqual(metric_data, expected_data) + self.assertEqual(len(EventTracker._events), 0) # Events should have been sent and cleared + + @patch( + "samcli.lib.telemetry.event.EventTracker.send_events", + return_value=None, + ) + @patch("samcli.lib.telemetry.event.Event") + def test_track_event_events_sent_when_capacity_reached(self, event_mock, send_mock): + # Create dummy Event creator to bypass verification + def make_mock_event(name, value): + dummy = Mock(event_name=Mock(value=name), event_value=value, thread_id=ANY, time_stamp=ANY) + dummy.to_json.return_value = Event.to_json(dummy) + return dummy + + event_mock.return_value = make_mock_event + + # Fill EventTracker with almost enough events to reach capacity + for i in range(EventTracker.MAX_EVENTS - 1): + EventTracker.track_event(f"Name{i}", f"Value{i}") + + send_mock.assert_not_called() + self.assertEqual(len(EventTracker._events), EventTracker.MAX_EVENTS - 1) + + # Add one more event to trigger sending all events + EventTracker.track_event("TheStrawThat", "BreaksTheCamel'sBack") + + # Wait for all threads to complete + for thread in threading.enumerate(): + if thread is threading.main_thread(): + continue + thread.join() + + send_mock.assert_called() + + +class TestTrackLongEvent(TestCase): + @patch("samcli.lib.telemetry.event.EventTracker.send_events") + @patch("samcli.lib.telemetry.event.EventTracker.track_event") + @patch("samcli.lib.telemetry.event.Event", return_value=None) + def test_long_event_is_tracked(self, event_mock, track_mock, send_mock): + mock_tracker = {} + mock_tracker["tracked_events"]: List[Tuple[str, str]] = [] # Tuple to bypass Event verification + mock_tracker["emitted_events"]: List[Tuple[str, str]] = [] + + def mock_track(name, value): + mock_tracker["tracked_events"].append((name, value)) + + def mock_send(): + mock_tracker["emitted_events"] = mock_tracker["tracked_events"] + mock_tracker["tracked_events"] = [] # Mimic clear_trackers() + + track_mock.side_effect = mock_track + send_mock.side_effect = mock_send + + @track_long_event("StartEvent", "StartValue", "EndEvent", "EndValue") + def func(): + self.assertEqual(len(mock_tracker["tracked_events"]), 1, "Starting event not tracked.") + self.assertIn(("StartEvent", "StartValue"), mock_tracker["tracked_events"], "Incorrect starting event.") + + func() + + self.assertEqual(len(mock_tracker["tracked_events"]), 0, "Tracked events not reset; send_events not called.") + self.assertEqual(len(mock_tracker["emitted_events"]), 2, "Unexpected number of emitted events.") + self.assertIn(("StartEvent", "StartValue"), mock_tracker["emitted_events"], "Starting event not tracked.") + self.assertIn(("EndEvent", "EndValue"), mock_tracker["emitted_events"], "Ending event not tracked.") + + @patch("samcli.lib.telemetry.event.EventTracker.send_events") + @patch("samcli.lib.telemetry.event.EventTracker.track_event") + def test_nothing_tracked_if_invalid_events(self, track_mock, send_mock): + mock_tracker = {} + mock_tracker["tracked_events"]: List[Tuple[str, str]] = [] # Tuple to bypass Event verification + mock_tracker["emitted_events"]: List[Tuple[str, str]] = [] + + def mock_track(name, value): + mock_tracker["tracked_events"].append((name, value)) + + def mock_send(): + mock_tracker["emitted_events"] = mock_tracker["tracked_events"] + mock_tracker["tracked_events"] = [] # Mimic clear_trackers() + + track_mock.side_effect = mock_track + send_mock.side_effect = mock_send + + @track_long_event("DefinitelyNotARealEvent", "Nope", "ThisEventDoesntExist", "NuhUh") + def func(): + self.assertEqual(len(mock_tracker["tracked_events"]), 0, "Events should not have been tracked.") + + func() + + self.assertEqual(len(mock_tracker["tracked_events"]), 0, "Events should not have been tracked.") + self.assertEqual(len(mock_tracker["emitted_events"]), 0, "Events should not have been emitted.") + track_mock.assert_not_called() # Tracker should not have been called + send_mock.assert_not_called() # Sender should not have been called diff --git a/tests/unit/lib/telemetry/test_metric.py b/tests/unit/lib/telemetry/test_metric.py index 3cfc680a2e..7835b8b1a8 100644 --- a/tests/unit/lib/telemetry/test_metric.py +++ b/tests/unit/lib/telemetry/test_metric.py @@ -9,6 +9,7 @@ from unittest import TestCase from unittest.mock import patch, Mock, ANY, call +from samcli.lib.telemetry.event import EventTracker import samcli.lib.telemetry.metric from samcli.lib.telemetry.cicd import CICDPlatform @@ -132,6 +133,7 @@ def setUp(self): GlobalConfigClassMock = Mock() self.telemetry_instance = TelemetryClassMock.return_value = Mock() self.gc_instance_mock = GlobalConfigClassMock.return_value = Mock() + EventTracker.clear_trackers() self.telemetry_class_patcher = patch("samcli.lib.telemetry.metric.Telemetry", TelemetryClassMock) self.gc_patcher = patch("samcli.lib.telemetry.metric.GlobalConfig", GlobalConfigClassMock) @@ -181,6 +183,7 @@ def real_fn(): "debugFlagProvided": False, "region": "myregion", "commandName": "fakesam local invoke", + "metricSpecificAttributes": ANY, "duration": ANY, "exitReason": "success", "exitCode": 0, @@ -338,6 +341,18 @@ def real_fn(a, b=None): "The command metrics be emitted when used as a decorator", ) + @patch("samcli.lib.telemetry.event.EventTracker.send_events", return_value=None) + @patch("samcli.lib.telemetry.metric.Context") + def test_must_send_events(self, ContextMock, send_mock): + ContextMock.get_current_context.return_value = self.context_mock + + def real_fn(): + pass + + track_command(real_fn)() + + send_mock.assert_called() + class TestParameterCapture(TestCase): def setUp(self): diff --git a/tests/unit/lib/telemetry/test_project_metadata.py b/tests/unit/lib/telemetry/test_project_metadata.py new file mode 100644 index 0000000000..b165e912fa --- /dev/null +++ b/tests/unit/lib/telemetry/test_project_metadata.py @@ -0,0 +1,119 @@ +""" +Module for testing the project_metadata.py methods. +""" + +import hashlib +from subprocess import CompletedProcess, CalledProcessError +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() + expected_hash = hashlib.sha256() + expected_hash.update(expected.encode("utf-8")) + self.assertEqual(git_origin, expected_hash.hexdigest()) + + @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() + expected_hash = hashlib.sha256() + expected_hash.update(expected.encode("utf-8")) + self.assertEqual(project_name, expected_hash.hexdigest()) + + @parameterized.expand( + [ + ("C:/Users/aws/path/to/library/aws-sam-cli"), + ("C:\\Users\\aws\\Windows\\path\\aws-sam-cli"), + ("C:/"), + ("C:\\"), + ("E:/path/to/another/dir"), + ("This/one/doesn't/start/with/a/letter"), + ("/banana"), + ("D:/one/more/just/to/be/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, 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() + expected_hash = hashlib.sha256() + expected_hash.update(cwd.replace("\\", "/").encode("utf-8")) + self.assertEqual(project_name, expected_hash.hexdigest()) + + @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() + expected_hash = hashlib.sha256() + expected_hash.update(git_hash.encode("utf-8")) + self.assertEqual(initial_commit, expected_hash.hexdigest())