samcli/lib/telemetry/metric.py (267 lines of code) (raw):
"""
Provides methods to generate and send metrics
"""
import logging
import platform
import uuid
from dataclasses import dataclass
from functools import reduce, wraps
from pathlib import Path
from timeit import default_timer
from typing import Any, Dict, Optional
import click
from samcli import __version__ as samcli_version
from samcli.cli.context import Context
from samcli.cli.global_config import GlobalConfig
from samcli.commands._utils.experimental import get_all_experimental_statues
from samcli.commands.exceptions import UnhandledException, UserException
from samcli.lib.hook.exceptions import InvalidHookPackageConfigException
from samcli.lib.hook.hook_config import HookPackageConfig
from samcli.lib.hook.hook_wrapper import INTERNAL_PACKAGES_ROOT
from samcli.lib.hook.utils import get_hook_metadata
from samcli.lib.iac.cdk.utils import is_cdk_project
from samcli.lib.iac.plugins_interfaces import ProjectTypes
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_initial_commit_hash, get_project_name
from samcli.lib.telemetry.telemetry import Telemetry
from samcli.lib.telemetry.user_agent import get_user_agent_string
from samcli.lib.warnings.sam_cli_warning import TemplateWarningsChecker
LOG = logging.getLogger(__name__)
WARNING_ANNOUNCEMENT = "WARNING: {}"
"""
Global variables are evil but this is a justified usage.
This creates a versatile telemetry tracking no matter where in the code. Something like a Logger.
No side effect will result in this as it is write-only for code outside of telemetry.
Decorators should be used to minimize logic involving telemetry.
"""
_METRICS = dict()
@dataclass
class ProjectDetails:
project_type: str
hook_name: Optional[str]
hook_package_version: Optional[str]
def send_installed_metric():
LOG.debug("Sending Installed Metric")
telemetry = Telemetry()
metric = Metric("installed")
metric.add_data("osPlatform", platform.system())
metric.add_data("telemetryEnabled", bool(GlobalConfig().telemetry_enabled))
telemetry.emit(metric, force_emit=True)
def track_template_warnings(warning_names):
"""
Decorator to track when a warning is emitted. This method accepts name of warning and executes the function,
gathers all relevant metrics, reports the metrics and returns.
On your warning check method use as follows
@track_warning(['Warning1', 'Warning2'])
def check():
return True, 'Warning applicable'
"""
def decorator(func):
"""
Actual decorator method with warning names
"""
def wrapped(*args, **kwargs):
telemetry = Telemetry()
template_warning_checker = TemplateWarningsChecker()
ctx = Context.get_current_context()
try:
ctx.template_dict
except AttributeError:
LOG.debug("Ignoring warning check as template is not provided in context.")
return func(*args, **kwargs)
for warning_name in warning_names:
warning_message = template_warning_checker.check_template_for_warning(warning_name, ctx.template_dict)
metric = Metric("templateWarning")
metric.add_data("awsProfileProvided", bool(ctx.profile))
metric.add_data("debugFlagProvided", bool(ctx.debug))
metric.add_data("region", ctx.region or "")
metric.add_data("warningName", warning_name)
metric.add_data("warningCount", 1 if warning_message else 0) # 1-True or 0-False
telemetry.emit(metric)
if warning_message:
click.secho(WARNING_ANNOUNCEMENT.format(warning_message), fg="yellow")
return func(*args, **kwargs)
return wrapped
return decorator
def track_command(func):
"""
Decorator to track execution of a command. This method executes the function, gathers all relevant metrics,
reports the metrics and returns.
If you have a Click command, you can track as follows:
.. code:: python
@click.command(...)
@click.options(...)
@track_command
def hello_command():
print('hello')
"""
@wraps(func)
def wrapped(*args, **kwargs):
exception = None
return_value = None
exit_reason = "success"
exit_code = 0
duration_fn = _timer()
ctx = None
try:
# we have get_current_context in it's own try/except to catch the RuntimeError for this and not func()
ctx = Context.get_current_context()
except RuntimeError:
LOG.debug("Unable to find Click Context for getting session_id.")
try:
if ctx and ctx.exception:
# re-raise here to handle exception captured in context and not run func()
raise ctx.exception
# Execute the function and capture return value. This is returned by the wrapper
# First argument of all commands should be the Context
return_value = func(*args, **kwargs)
except (
UserException,
click.Abort,
click.BadOptionUsage,
click.BadArgumentUsage,
click.BadParameter,
click.UsageError,
) as ex:
# Capture exception information and re-raise it later,
# so metrics can be sent.
exception = ex
# NOTE(sriram-mv): Set exit code to 1 if deemed to be user fixable error.
exit_code = 1
if hasattr(ex, "wrapped_from") and ex.wrapped_from:
exit_reason = ex.wrapped_from
else:
exit_reason = type(ex).__name__
except Exception as ex:
command = ctx.command_path if ctx else ""
exception = UnhandledException(command, ex)
# Standard Unix practice to return exit code 255 on fatal/unhandled exit.
exit_code = 255
exit_reason = type(ex).__name__
if ctx:
time = duration_fn()
try:
# metrics also contain a call to Context.get_current_context, catch RuntimeError
_send_command_run_metrics(ctx, time, exit_reason, exit_code, **kwargs)
except RuntimeError:
LOG.debug("Unable to find Click context when sending metrics to telemetry")
if exception:
raise exception # pylint: disable=raising-bad-type
return return_value
return wrapped
def _send_command_run_metrics(ctx: Context, duration: int, exit_reason: str, exit_code: int, **kwargs) -> None:
"""
Emits metrics based on the results of a command run
Parameters
----------
ctx: Context
The click context containing parameters, options, etc
duration: int
The total run time of the command in milliseconds
exit_reason: str
The exit reason from the command, "success" if successful, otherwise name of exception
exit_code: int
The exit code of command run
"""
telemetry = Telemetry()
# get_all_experimental_statues() returns Dict[str, bool]
# since we append other values here (not just bool), need to explicitly set type
metric_specific_attributes: Dict[str, Any] = get_all_experimental_statues() if ctx.experimental else {}
try:
template_dict = ctx.template_dict
project_details = _get_project_details(kwargs.get("hook_name", ""), template_dict)
if project_details.project_type == ProjectTypes.CDK.value:
EventTracker.track_event("UsedFeature", "CDK")
metric_specific_attributes["projectType"] = project_details.project_type
if project_details.hook_name:
metric_specific_attributes["hookPackageId"] = project_details.hook_name
if project_details.hook_package_version:
metric_specific_attributes["hookPackageVersion"] = project_details.hook_package_version
except AttributeError:
LOG.debug("Template is not provided in context, skip adding project type metric")
metric_name = "commandRunExperimental" if ctx.experimental else "commandRun"
metric = Metric(metric_name)
metric.add_data("awsProfileProvided", bool(ctx.profile))
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 not ctx.command_path.endswith("init") or ctx.command_path.endswith("pipeline init"):
# Project metadata
# We don't capture below usage attributes for sam init as the command is not run inside a project
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)
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)
def _get_project_details(hook_name: str, template_dict: Dict) -> ProjectDetails:
if not hook_name:
hook_metadata = get_hook_metadata(template_dict)
if not hook_metadata:
project_type = ProjectTypes.CDK.value if is_cdk_project(template_dict) else ProjectTypes.CFN.value
return ProjectDetails(project_type=project_type, hook_name=None, hook_package_version=None)
hook_name = str(hook_metadata.get("HookName"))
hook_location = Path(INTERNAL_PACKAGES_ROOT, hook_name)
try:
hook_package_config = HookPackageConfig(package_dir=hook_location)
except InvalidHookPackageConfigException:
return ProjectDetails(project_type=hook_name, hook_name=hook_name, hook_package_version=None)
return ProjectDetails(
project_type=hook_package_config.iac_framework,
hook_name=hook_package_config.name,
hook_package_version=hook_package_config.version,
)
def _timer():
"""
Timer to measure the elapsed time between two calls in milliseconds. When you first call this method,
we will automatically start the timer. The return value is another method that, when called, will end the timer
and return the duration between the two calls.
..code:
>>> import time
>>> duration_fn = _timer()
>>> time.sleep(5) # Say, you sleep for 5 seconds in between calls
>>> duration_ms = duration_fn()
>>> print(duration_ms)
5010
Returns
-------
function
Call this method to end the timer and return duration in milliseconds
"""
start = default_timer()
def end():
# time might go backwards in rare scenarios, hence the 'max'
return int(max(default_timer() - start, 0) * 1000) # milliseconds
return end
def _parse_attr(obj, name):
"""
Get attribute from an object.
@param obj Object
@param name Attribute name to get from the object.
Can be nested with "." in between.
For example: config.random_field.value
"""
return reduce(getattr, name.split("."), obj)
def capture_parameter(metric_name, key, parameter_identifier, parameter_nested_identifier=None, as_list=False):
"""
Decorator for capturing one parameter of the function.
:param metric_name Name of the metric
:param key Key for storing the captured parameter
:param parameter_identifier Either a string for named parameter or int for positional parameter.
"self" can be accessed with 0.
:param parameter_nested_identifier If specified, the attribute pointed by this parameter will be stored instead.
Can be in nested format such as config.random_field.value.
:param as_list Default to False. Setting to True will append the captured parameter into
a list instead of overriding the previous one.
"""
def wrap(func):
@wraps(func)
def wrapped_func(*args, **kwargs):
return_value = func(*args, **kwargs)
if isinstance(parameter_identifier, int):
parameter = args[parameter_identifier]
elif isinstance(parameter_identifier, str):
parameter = kwargs[parameter_identifier]
else:
return return_value
if parameter_nested_identifier:
parameter = _parse_attr(parameter, parameter_nested_identifier)
if as_list:
add_metric_list_data(metric_name, key, parameter)
else:
add_metric_data(metric_name, key, parameter)
return return_value
return wrapped_func
return wrap
def capture_return_value(metric_name, key, as_list=False):
"""
Decorator for capturing the return value of the function.
:param metric_name Name of the metric
:param key Key for storing the captured parameter
:param as_list Default to False. Setting to True will append the captured parameter into
a list instead of overriding the previous one.
"""
def wrap(func):
@wraps(func)
def wrapped_func(*args, **kwargs):
return_value = func(*args, **kwargs)
if as_list:
add_metric_list_data(metric_name, key, return_value)
else:
add_metric_data(metric_name, key, return_value)
return return_value
return wrapped_func
return wrap
def add_metric_data(metric_name, key, value):
_get_metric(metric_name).add_data(key, value)
def add_metric_list_data(metric_name, key, value):
_get_metric(metric_name).add_list_data(key, value)
def _get_metric(metric_name):
if metric_name not in _METRICS:
_METRICS[metric_name] = Metric(metric_name)
return _METRICS[metric_name]
def emit_metric(metric_name):
if metric_name not in _METRICS:
return
telemetry = Telemetry()
telemetry.emit(_get_metric(metric_name))
_METRICS.pop(metric_name)
def emit_all_metrics():
for key in list(_METRICS):
emit_metric(key)
class Metric:
"""
Metric class to store metric data and adding common attributes
"""
def __init__(self, metric_name, should_add_common_attributes=True):
self._data = dict()
self._metric_name = metric_name
self._gc = GlobalConfig()
self._session_id = self._default_session_id()
self._cicd_detector = CICDDetector()
if not self._session_id:
self._session_id = ""
if should_add_common_attributes:
self._add_common_metric_attributes()
def add_list_data(self, key, value):
if key not in self._data:
self._data[key] = list()
if not isinstance(self._data[key], list):
# raise MetricDataNotList()
return
self._data[key].append(value)
def add_data(self, key, value):
self._data[key] = value
def get_data(self):
return self._data
def get_metric_name(self):
return self._metric_name
def _add_common_metric_attributes(self):
self._data["requestId"] = str(uuid.uuid4())
self._data["installationId"] = self._gc.installation_id
self._data["sessionId"] = self._session_id
self._data["executionEnvironment"] = self._get_execution_environment()
self._data["ci"] = bool(self._cicd_detector.platform())
self._data["pyversion"] = platform.python_version()
self._data["samcliVersion"] = samcli_version
user_agent = get_user_agent_string()
if user_agent:
self._data["userAgent"] = user_agent
@staticmethod
def _default_session_id() -> Optional[str]:
"""
Get the default SessionId from Click Context.
Fail silently if Context does not exist.
"""
try:
ctx = Context.get_current_context()
if ctx:
return ctx.session_id
return None
except RuntimeError:
LOG.debug("Unable to find Click Context for getting session_id.")
return None
def _get_execution_environment(self) -> str:
"""
Returns the environment in which SAM CLI is running. Possible options are:
CLI (default) - SAM CLI was executed from terminal or a script.
other CICD platform name - SAM CLI was executed in CICD
Returns
-------
str
Name of the environment where SAM CLI is executed in.
"""
cicd_platform: Optional[CICDPlatform] = self._cicd_detector.platform()
if cicd_platform:
return cicd_platform.name
return "CLI"
class MetricDataNotList(Exception):
pass