Repository: CartoDB/cartoframes
Pull request #1481 (2020-01-13 16:16:33)

Add metrics
Closes https://github.com/CartoDB/cartoframes/issues/1457

```
{
    'event_version': '1',
    'event_time': '2020-01-13T17:39:31.783459+01:00',
    'event_source': 'cartoframes',
    'event_name': 'data_uploaded',
    'source_version': '1.0.0dev0',
    'installation_id': '34b4ea42-62e5-4246-b697-bcc493e30a68',
    'runtime_env': 'cli'
}
```

Available runtime environments are: cli, notebook, google.colab.
Available event names are data_downloaded, data_uploaded, map_created, map_published.

There is also a method `setup_metrics` to enable/disable the metrics. This flag is also saved in the user config with the uuid.

```py
from cartoframes.utils import setup_metrics

setup_metrics(False)  #  To disable the metrics
```


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 45bc0774..cb9ebb22 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## [Unreleased]
+
+### Added
+- Add metrics and utils.setup_metrics function (#1457)
+
 ## [1.0rc1] - 2020-01-10
 
 [RC1 Migration Guide](/docs/developers/migrations/rc1.md)
diff --git a/cartoframes/_version.py b/cartoframes/_version.py
index 996d8d3f..890c6c39 100644
--- a/cartoframes/_version.py
+++ b/cartoframes/_version.py
@@ -1,1 +1,1 @@
-__version__ = '1.0rc1'
+__version__ = '1.0.0dev0'
diff --git a/cartoframes/auth/credentials.py b/cartoframes/auth/credentials.py
index fa63386f..78f1a51c 100644
--- a/cartoframes/auth/credentials.py
+++ b/cartoframes/auth/credentials.py
@@ -1,8 +1,6 @@
 """Credentials management for CARTOframes usage."""
 
 import os
-import json
-import appdirs
 
 from urllib.parse import urlparse
 from carto.auth import APIKeyAuthClient
@@ -10,13 +8,13 @@ from carto.do_token import DoTokenManager
 
 from .. import __version__
 from ..utils.logger import log
-from ..utils.utils import is_valid_str, check_do_enabled
+from ..utils.utils import is_valid_str, check_do_enabled, save_in_config, \
+                          read_from_config, default_config_path
 
 from warnings import filterwarnings
 filterwarnings('ignore', category=FutureWarning, module='carto')
 
-_USER_CONFIG_DIR = appdirs.user_config_dir('cartoframes')
-_DEFAULT_PATH = os.path.join(_USER_CONFIG_DIR, 'cartocreds.json')
+DEFAULT_CREDS_FILENAME = 'creds.json'
 
 
 class Credentials:
@@ -121,8 +119,10 @@ class Credentials:
 
         """
         try:
-            with open(config_file or _DEFAULT_PATH, 'r') as f:
-                credentials = json.load(f)
+            if config_file is None:
+                credentials = read_from_config(filename=DEFAULT_CREDS_FILENAME)
+            else:
+                credentials = read_from_config(filepath=config_file)
             return cls(
                 credentials.get('username'),
                 credentials.get('api_key'),
@@ -171,18 +171,18 @@ class Credentials:
             User credentials for `johnsmith` were successfully saved to `creds.json`
 
         """
-        if config_file is None:
-            config_file = _DEFAULT_PATH
+        content = {
+            'username': self._username,
+            'api_key': self._api_key,
+            'base_url': self._base_url
+        }
 
-            # create directory if not exists
-            if not os.path.exists(_USER_CONFIG_DIR):
-                os.makedirs(_USER_CONFIG_DIR)
+        if config_file is None:
+            config_file = save_in_config(content, filename=DEFAULT_CREDS_FILENAME)
+        else:
+            config_file = save_in_config(content, filepath=config_file)
 
-        with open(config_file, 'w') as _file:
-            json.dump({
-                'username': self._username,
-                'api_key': self._api_key,
-                'base_url': self._base_url}, _file)
+        if config_file is not None:
             log.info('User credentials for `{0}` were successfully saved to `{1}`'.format(
                 self._username or self._base_url, config_file))
 
@@ -206,7 +206,7 @@ class Credentials:
             base_url='https://johnsmith.carto.com/')
 
         """
-        path_to_remove = config_file or _DEFAULT_PATH
+        path_to_remove = config_file or default_config_path(DEFAULT_CREDS_FILENAME)
 
         try:
             os.remove(path_to_remove)
diff --git a/cartoframes/io/carto.py b/cartoframes/io/carto.py
index d4d83587..50f9fe36 100644
--- a/cartoframes/io/carto.py
+++ b/cartoframes/io/carto.py
@@ -9,6 +9,7 @@ from .managers.context_manager import ContextManager
 from ..utils.geom_utils import set_geometry, has_geometry
 from ..utils.logger import log
 from ..utils.utils import is_valid_str, is_sql_query
+from ..utils.metrics import send_metrics
 
 
 GEOM_COLUMN_NAME = 'the_geom'
@@ -16,6 +17,7 @@ GEOM_COLUMN_NAME = 'the_geom'
 IF_EXISTS_OPTIONS = ['fail', 'replace', 'append']
 
 
+@send_metrics('data_downloaded')
 def read_carto(source, credentials=None, limit=None, retry_times=3, schema=None, index_col=None, decode_geom=True):
     """Read a table or a SQL query from the CARTO account.
 
@@ -61,6 +63,7 @@ def read_carto(source, credentials=None, limit=None, retry_times=3, schema=None,
     return gdf
 
 
+@send_metrics('data_uploaded')
 def to_carto(dataframe, table_name, credentials=None, if_exists='fail', geom_col=None, index=False, index_label=None,
              cartodbfy=True, log_enabled=True):
     """Upload a Dataframe to CARTO.
diff --git a/cartoframes/utils/__init__.py b/cartoframes/utils/__init__.py
index a0d4ddc0..865af5c2 100644
--- a/cartoframes/utils/__init__.py
+++ b/cartoframes/utils/__init__.py
@@ -1,7 +1,9 @@
 from .logger import set_log_level
 from .geom_utils import decode_geometry
+from .metrics import setup_metrics
 
 __all__ = [
+    'setup_metrics',
     'set_log_level',
     'decode_geometry'
 ]
diff --git a/cartoframes/utils/metrics.py b/cartoframes/utils/metrics.py
new file mode 100644
index 00000000..636f09aa
--- /dev/null
+++ b/cartoframes/utils/metrics.py
@@ -0,0 +1,101 @@
+import os
+import uuid
+import requests
+
+from .logger import log
+from .utils import default_config_path, read_from_config, save_in_config, \
+                   is_uuid, get_local_time, silent_fail, get_runtime_env
+from .. import __version__
+
+EVENT_VERSION = '1'
+EVENT_SOURCE = 'cartoframes'
+
+UUID_KEY = 'uuid'
+ENABLED_KEY = 'enabled'
+METRICS_FILENAME = 'metrics.json'
+
+_metrics_config = None
+
+
+def setup_metrics(enabled):
+    '''Update the metrics configuration.
+
+    Args:
+        enabled (bool): flag to enable/disable metrics.
+
+    '''
+    global _metrics_config
+
+    _metrics_config[ENABLED_KEY] = enabled
+
+    save_in_config(_metrics_config, filename=METRICS_FILENAME)
+
+
+@silent_fail
+def init_metrics_config():
+    global _metrics_config
+
+    filepath = default_config_path(METRICS_FILENAME)
+
+    if _metrics_config is None:
+        if os.path.exists(filepath):
+            _metrics_config = read_from_config(filepath=filepath)
+
+        if not check_valid_metrics_uuid(_metrics_config):
+            _metrics_config = create_metrics_config()
+            save_in_config(_metrics_config, filename=METRICS_FILENAME)
+
+
+def create_metrics_config():
+    return {
+        UUID_KEY: str(uuid.uuid4()),
+        ENABLED_KEY: True
+    }
+
+
+def get_metrics_uuid():
+    if _metrics_config is not None:
+        return _metrics_config.get(UUID_KEY)
+
+
+def get_metrics_enabled():
+    if _metrics_config is not None:
+        return _metrics_config.get(ENABLED_KEY)
+
+
+def check_valid_metrics_uuid(metrics_config):
+    return metrics_config is not None and is_uuid(metrics_config.get(UUID_KEY))
+
+
+def build_metrics_data(event_name):
+    return {
+        'event_version': EVENT_VERSION,
+        'event_time': get_local_time(),
+        'event_source': EVENT_SOURCE,
+        'event_name': event_name,
+        'source_version': __version__,
+        'installation_id': get_metrics_uuid(),
+        'runtime_env': get_runtime_env()
+    }
+
+
+@silent_fail
+def post_metrics(event_name):
+    if get_metrics_enabled():
+        json_data = build_metrics_data(event_name)
+        result = requests.post('https://carto.com/api/metrics', json=json_data, timeout=2)
+        log.debug('Metrics sent! {0} {1}'.format(result.status_code, json_data))
+
+
+def send_metrics(event_name):
+    def decorator_func(func):
+        def wrapper_func(*args, **kwargs):
+            result = func(*args, **kwargs)
+            post_metrics(event_name)
+            return result
+        return wrapper_func
+    return decorator_func
+
+
+# Run this once
+init_metrics_config()
diff --git a/cartoframes/utils/utils.py b/cartoframes/utils/utils.py
index e61b0eac..ebbf7989 100644
--- a/cartoframes/utils/utils.py
+++ b/cartoframes/utils/utils.py
@@ -1,20 +1,22 @@
 """general utility functions"""
 
+import os
 import re
 import gzip
 import json
 import time
 import base64
+import appdirs
 import decimal
 import hashlib
 import requests
 import geopandas
-
 import numpy as np
 import pkg_resources
 import semantic_version
 
 from functools import wraps
+from datetime import datetime, timezone
 from warnings import catch_warnings, filterwarnings
 from pyrestcli.exceptions import ServerErrorException
 from pandas.api.types import is_datetime64_any_dtype as is_datetime
@@ -28,6 +30,8 @@ GEOM_TYPE_POLYGON = 'polygon'
 
 PG_NULL = '__null'
 
+USER_CONFIG_DIR = appdirs.user_config_dir('cartoframes')
+
 
 def map_geom_type(geom_type):
     return {
@@ -322,6 +326,11 @@ def is_json_filepath(text):
     return re.match(r'^.*\.json\s*$', text, re.IGNORECASE)
 
 
+def is_uuid(text):
+    if text is not None:
+        return re.match(r'^[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}$', text)
+
+
 def get_credentials(credentials=None):
     from ..auth import defaults
     _credentials = credentials or defaults.get_default_credentials()
@@ -401,6 +410,11 @@ def remove_comments(text):
     return re.sub(pattern, replacer, text).strip()
 
 
+def get_local_time():
+    local_time = datetime.now(timezone.utc).astimezone()
+    return local_time.isoformat()
+
+
 def timelogger(method):
     def fn(*args, **kw):
         start = time.time()
@@ -463,3 +477,46 @@ def is_ipython_notebook():
             return False
     except NameError:
         return False
+
+
+def get_runtime_env():
+    if is_ipython_notebook():
+        kernel_class = get_ipython().config['IPKernelApp'].get('kernel_class', '')  # noqa: F821
+        if kernel_class.startswith('google.colab'):
+            return 'google.colab'
+        else:
+            return 'notebook'
+    else:
+        return 'cli'
+
+
+def save_in_config(content, filename=None, filepath=None):
+    if filepath is None:
+        if not os.path.exists(USER_CONFIG_DIR):
+            os.makedirs(USER_CONFIG_DIR)
+        filepath = default_config_path(filename)
+
+    with open(filepath, 'w') as f:
+        json.dump(content, f)
+        return filepath
+
+
+def read_from_config(filename=None, filepath=None):
+    if filepath is None:
+        filepath = default_config_path(filename)
+
+    with open(filepath, 'r') as f:
+        return json.load(f)
+
+
+def default_config_path(filename):
+    return os.path.join(USER_CONFIG_DIR, filename)
+
+
+def silent_fail(method):
+    def fn(*args, **kw):
+        try:
+            return method(*args, **kw)
+        except Exception:
+            pass
+    return fn
diff --git a/cartoframes/viz/map.py b/cartoframes/viz/map.py
index 00140083..477a3d2d 100644
--- a/cartoframes/viz/map.py
+++ b/cartoframes/viz/map.py
@@ -8,6 +8,7 @@ from .html import HTMLMap
 from .basemaps import Basemaps
 from .kuviz import KuvizPublisher
 from ..utils.utils import get_center
+from ..utils.metrics import send_metrics
 
 WORLD_BOUNDS = [[-180, -90], [180, 90]]
 
@@ -136,6 +137,7 @@ class Map:
                 'pitch': viewport.get('pitch')
             }
 
+    @send_metrics('map_created')
     def _repr_html_(self):
         self._html_map = HTMLMap()
 
@@ -179,6 +181,7 @@ class Map:
             '_airship_path': self._airship_path
         }
 
+    @send_metrics('map_published')
     def publish(self, name, password, table_name=None, credentials=None):
         """Publish the map visualization as a CARTO custom visualization.
 


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 00000000..4a034a9a
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,31 @@
+from cartoframes.utils import setup_metrics
+
+
+def pytest_configure(config):
+    """
+    Allows plugins and conftest files to perform initial configuration.
+    This hook is called for every plugin and initial conftest
+    file after command line options have been parsed.
+    """
+    setup_metrics(False)
+
+
+def pytest_sessionstart(session):
+    """
+    Called after the Session object has been created and
+    before performing collection and entering the run test loop.
+    """
+
+
+def pytest_sessionfinish(session, exitstatus):
+    """
+    Called after whole test run finished, right before
+    returning the exit status to the system.
+    """
+
+
+def pytest_unconfigure(config):
+    """
+    called before test process is exited.
+    """
+    setup_metrics(True)
diff --git a/tests/unit/auth/test_credentials.py b/tests/unit/auth/test_credentials.py
index f0c2e60f..06b7901a 100644
--- a/tests/unit/auth/test_credentials.py
+++ b/tests/unit/auth/test_credentials.py
@@ -3,7 +3,10 @@ import os
 import pytest
 
 from cartoframes.auth import Credentials
-from cartoframes.auth.credentials import _DEFAULT_PATH, _USER_CONFIG_DIR
+from cartoframes.utils.utils import default_config_path
+from cartoframes.auth.credentials import DEFAULT_CREDS_FILENAME
+
+DEFAULT_PATH = default_config_path(DEFAULT_CREDS_FILENAME)
 
 
 class TestCredentials:
@@ -122,21 +125,15 @@ class TestCredentials:
 class TestCredentialsFromFile:
     def setup_method(self, method):
         # remove default credential file
-        if os.path.exists(_DEFAULT_PATH):
-            os.remove(_DEFAULT_PATH)
-        # delete path for user config data
-        if os.path.exists(_USER_CONFIG_DIR):
-            os.rmdir(_USER_CONFIG_DIR)
+        if os.path.exists(DEFAULT_PATH):
+            os.remove(DEFAULT_PATH)
 
         self.api_key = 'fake_api_key'
         self.username = 'fake_user'
 
     def teardown_method(self, method):
-        if os.path.exists(_DEFAULT_PATH):
-            os.remove(_DEFAULT_PATH)
-
-        if os.path.exists(_USER_CONFIG_DIR):
-            os.rmdir(_USER_CONFIG_DIR)
+        if os.path.exists(DEFAULT_PATH):
+            os.remove(DEFAULT_PATH)
 
     def test_credentials_without_file(self, mocker):
         mocker_log = mocker.patch('cartoframes.utils.logger.log.info')
@@ -149,7 +146,7 @@ class TestCredentialsFromFile:
         assert credentials1 == credentials2
         mocker_log.assert_called_once_with(
             'User credentials for `{0}` were successfully saved to `{1}`'.format(
-                self.username, _DEFAULT_PATH))
+                self.username, DEFAULT_PATH))
 
         credentials1.delete()