Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Telemetry Implementation #1287

Merged
merged 3 commits into from
Jul 24, 2019
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
.idea/**/tasks.xml
.idea/dictionaries
.idea
.vscode

# Sensitive or high-churn files:
.idea/**/dataSources/
Expand Down
12 changes: 12 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,15 @@ also forces commands implementations to be modular, reusable, and highly
customizable. When RC files are implemented, new commands can be added
or existing commands can be removed, with simple a configuration in the
RC file.

Internal Environment Variables
==============================

SAM CLI uses the following internal, undocumented, environment variables
for development purposes. They should *not* be used by customers:

- `__SAM_CLI_APP_DIR`: Path to application directory to be used in place
of `~/.aws-sam` directory.

- `__SAM_CLI_TELEMETRY_ENDPOINT_URL`: HTTP Endpoint where the Telemetry
metrics will be published to
6 changes: 6 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# Default value for environment variable. Can be overridden by setting the
# environment variable.
SAM_CLI_TELEMETRY ?= 0

init:
SAM_CLI_DEV=1 pip install -e '.[dev]'

Expand All @@ -8,10 +12,12 @@ test:

integ-test:
# Integration tests don't need code coverage
@echo Telemetry Status: $(SAM_CLI_TELEMETRY)
SAM_CLI_DEV=1 pytest tests/integration

func-test:
# Verify function test coverage only for `samcli.local` package
@echo Telemetry Status: $(SAM_CLI_TELEMETRY)
pytest --cov samcli.local --cov samcli.commands.local --cov-report term-missing tests/functional

flake:
Expand Down
60 changes: 60 additions & 0 deletions samcli/cli/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
Context information passed to each CLI command
"""

import uuid
import logging
import boto3
import click


class Context(object):
Expand All @@ -26,6 +28,7 @@ def __init__(self):
self._debug = False
self._aws_region = None
self._aws_profile = None
self._session_id = str(uuid.uuid4())

@property
def debug(self):
Expand Down Expand Up @@ -68,6 +71,63 @@ def profile(self, value):
self._aws_profile = value
self._refresh_session()

@property
def session_id(self):
"""
Returns the ID of this command session. This is a randomly generated UUIDv4 which will not change until the
command terminates.
"""
return self._session_id

@property
def command_path(self):
"""
Returns the full path of the command as invoked ex: "sam local generate-event s3 put". Wrapper to
https://click.palletsprojects.com/en/7.x/api/#click.Context.command_path

Returns
-------
str
Full path of the command invoked
"""

# Uses Click's Core Context. Note, this is different from this class, also confusingly named `Context`.
# Click's Core Context object is the one that contains command path information.
click_core_ctx = click.get_current_context()
if click_core_ctx:
return click_core_ctx.command_path

@staticmethod
def get_current_context():
"""
Get the current Context object from Click's context stacks. This method is safe to run within the
actual command's handler that has a ``@pass_context`` annotation. Outside of the handler, you run
the risk of creating a new Context object which is entirely different from the Context object used by your
command.
.. code:
@pass_context
def my_command_handler(ctx):
# You will get the right context from within the command handler. This will also work from any
# downstream method invoked as part of the handler.
this_context = Context.get_current_context()
assert ctx == this_context
Returns
-------
samcli.cli.context.Context
Instance of this object, if we are running in a Click command. None otherwise.
"""

# Click has the concept of Context stacks. Think of them as linked list containing custom objects that are
# automatically accessible at different levels. We start from the Core Click context and discover the
# SAM CLI command-specific Context object which contains values for global options used by all commands.
#
# https://click.palletsprojects.com/en/7.x/complex/#ensuring-object-creation
#

click_core_ctx = click.get_current_context()
if click_core_ctx:
return click_core_ctx.find_object(Context) or click_core_ctx.ensure_object(Context)

def _refresh_session(self):
"""
Update boto3's default session by creating a new session based on values set in the context. Some properties of
Expand Down
205 changes: 205 additions & 0 deletions samcli/cli/global_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
"""
Provides global configuration helpers.
"""

import json
import logging
import uuid
import os

import click

try:
from pathlib import Path
except ImportError: # pragma: no cover
from pathlib2 import Path # pragma: no cover

LOG = logging.getLogger(__name__)

CONFIG_FILENAME = "metadata.json"
INSTALLATION_ID_KEY = "installationId"
TELEMETRY_ENABLED_KEY = "telemetryEnabled"


class GlobalConfig(object):
"""
Contains helper methods for global configuration files and values. Handles
configuration file creation, updates, and fetching in a platform-neutral way.

Generally uses '~/.aws-sam/' or 'C:\\Users\\<user>\\AppData\\Roaming\\AWS SAM' as
the base directory, depending on platform.
"""

def __init__(self, config_dir=None, installation_id=None, telemetry_enabled=None):
"""
Initializes the class, with options provided to assist with testing.

:param config_dir: Optional, overrides the default config directory path.
:param installation_id: Optional, will use this installation id rather than checking config values.
"""
self._config_dir = config_dir
self._installation_id = installation_id
self._telemetry_enabled = telemetry_enabled

@property
def config_dir(self):
if not self._config_dir:
# Internal Environment variable to customize SAM CLI App Dir. Currently used only by integ tests.
app_dir = os.getenv("__SAM_CLI_APP_DIR")
self._config_dir = Path(app_dir) if app_dir else Path(click.get_app_dir('AWS SAM', force_posix=True))

return Path(self._config_dir)

@property
def installation_id(self):
"""
Returns the installation UUID for this AWS SAM CLI installation. If the
installation id has not yet been set, it will be set before returning.

Examples
--------

>>> gc = GlobalConfig()
>>> gc.installation_id
"7b7d4db7-2f54-45ba-bf2f-a2cbc9e74a34"

>>> gc = GlobalConfig()
>>> gc.installation_id
None

Returns
-------
A string containing the installation UUID, or None in case of an error.
"""
if self._installation_id:
return self._installation_id
try:
self._installation_id = self._get_or_set_uuid(INSTALLATION_ID_KEY)
return self._installation_id
except (ValueError, IOError):
return None

@property
def telemetry_enabled(self):
"""
Check if telemetry is enabled for this installation. Default value of
False. It first tries to get value from SAM_CLI_TELEMETRY environment variable. If its not set,
then it fetches the value from config file.

To enable telemetry, set SAM_CLI_TELEMETRY environment variable equal to integer 1 or string '1'.
All other values including words like 'True', 'true', 'false', 'False', 'abcd' etc will disable Telemetry

Examples
--------

>>> gc = GlobalConfig()
>>> gc.telemetry_enabled
True

Returns
-------
Boolean flag value. True if telemetry is enabled for this installation,
False otherwise.
"""
if self._telemetry_enabled is not None:
return self._telemetry_enabled

# If environment variable is set, its value takes precedence over the value from config file.
env_name = "SAM_CLI_TELEMETRY"
if env_name in os.environ:
return os.getenv(env_name) in ('1', 1)

try:
self._telemetry_enabled = self._get_value(TELEMETRY_ENABLED_KEY)
return self._telemetry_enabled
except (ValueError, IOError) as ex:
LOG.debug("Error when retrieving telemetry_enabled flag", exc_info=ex)
return False

@telemetry_enabled.setter
def telemetry_enabled(self, value):
"""
Sets the telemetry_enabled flag to the provided boolean value.

Examples
--------
>>> gc = GlobalConfig()
>>> gc.telemetry_enabled
False
>>> gc.telemetry_enabled = True
>>> gc.telemetry_enabled
True

Raises
------
IOError
If there are errors opening or writing to the global config file.

JSONDecodeError
If the config file exists, and is not valid JSON.
"""
self._set_value("telemetryEnabled", value)
self._telemetry_enabled = value

def _get_value(self, key):
cfg_path = self._get_config_file_path(CONFIG_FILENAME)
if not cfg_path.exists():
return None
with open(str(cfg_path)) as fp:
body = fp.read()
json_body = json.loads(body)
return json_body.get(key)

def _set_value(self, key, value):
cfg_path = self._get_config_file_path(CONFIG_FILENAME)
if not cfg_path.exists():
return self._set_json_cfg(cfg_path, key, value)
with open(str(cfg_path)) as fp:
body = fp.read()
try:
json_body = json.loads(body)
except ValueError as ex:
LOG.debug("Failed to decode JSON in {cfg_path}", exc_info=ex)
raise ex
return self._set_json_cfg(cfg_path, key, value, json_body)

def _create_dir(self):
self.config_dir.mkdir(mode=0o700, parents=True, exist_ok=True)

def _get_config_file_path(self, filename):
self._create_dir()
filepath = self.config_dir.joinpath(filename)
return filepath

def _get_or_set_uuid(self, key):
"""
Special logic method for when we want a UUID to always be present, this
method behaves as a getter with side effects. Essentially, if the value
is not present, we will set it with a generated UUID.

If we have multiple such values in the future, a possible refactor is
to just be _get_or_set_value, where we also take a default value as a
parameter.
"""
cfg_value = self._get_value(key)
if cfg_value is not None:
return cfg_value
return self._set_value(key, str(uuid.uuid4()))

def _set_json_cfg(self, filepath, key, value, json_body=None):
"""
Special logic method to add a value to a JSON configuration file. This
method will write a new version of the file in question, so it will
either write a new file with only the first config value, or if a JSON
body is provided, it will upsert starting from that JSON body.
"""
json_body = json_body or {}
json_body[key] = value
file_body = json.dumps(json_body, indent=4) + "\n"
try:
with open(str(filepath), 'w') as f:
f.write(file_body)
except IOError as ex:
LOG.debug("Error writing to {filepath}", exc_info=ex)
raise ex
return value
35 changes: 33 additions & 2 deletions samcli/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,22 @@
import click

from samcli import __version__
from samcli.lib.telemetry.metrics import send_installed_metric
from .options import debug_option, region_option, profile_option
from .context import Context
from .command import BaseCommand
from .global_config import GlobalConfig

logger = logging.getLogger(__name__)
LOG = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(message)s', datefmt='%Y-%m-%d %H:%M:%S')


pass_context = click.make_pass_decorator(Context)


global_cfg = GlobalConfig()


def common_options(f):
"""
Common CLI options used by all commands. Ex: --debug
Expand Down Expand Up @@ -48,6 +53,17 @@ def print_info(ctx, param, value):
ctx.exit()


# Keep the message to 80chars wide to it prints well on most terminals
TELEMETRY_PROMPT = """
\tTelemetry has been enabled for SAM CLI.
\t
\tYou can OPT OUT of telemetry by setting the environment variable
\tSAM_CLI_TELEMETRY=0 in your shell.

\tLearn More: http://docs.aws.amazon.com/serverless-application-model/latest/developerguide/telemetry-opt-out
"""


@click.command(cls=BaseCommand)
@common_options
@click.version_option(version=__version__, prog_name="SAM CLI")
Expand All @@ -62,4 +78,19 @@ def cli(ctx):
You can find more in-depth guide about the SAM specification here:
https://github.com/awslabs/serverless-application-model.
"""
pass

if global_cfg.telemetry_enabled is None:
enabled = True

try:
global_cfg.telemetry_enabled = enabled

if enabled:
click.secho(TELEMETRY_PROMPT, fg="yellow", err=True)

# When the Telemetry prompt is printed, we can safely assume that this is the first time someone
# is installing SAM CLI on this computer. So go ahead and send the `installed` metric
send_installed_metric()

except (IOError, ValueError) as ex:
LOG.debug("Unable to write telemetry flag", exc_info=ex)
Loading