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

[dashboard] New, add statsd incr to the API #9519

Merged
merged 12 commits into from
Apr 16, 2020
11 changes: 10 additions & 1 deletion superset/dashboards/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,11 @@
)
from superset.models.dashboard import Dashboard
from superset.views.base import generate_download_headers
from superset.views.base_api import BaseSupersetModelRestApi, RelatedFieldFilter
from superset.views.base_api import (
BaseSupersetModelRestApi,
RelatedFieldFilter,
statsd_metrics,
)
from superset.views.filters import FilterRelatedOwners

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -126,6 +130,7 @@ class DashboardRestApi(BaseSupersetModelRestApi):
@expose("/", methods=["POST"])
@protect()
@safe
@statsd_metrics
def post(self) -> Response:
"""Creates a new Dashboard
---
Expand Down Expand Up @@ -178,6 +183,7 @@ def post(self) -> Response:
@expose("/<pk>", methods=["PUT"])
@protect()
@safe
@statsd_metrics
def put( # pylint: disable=too-many-return-statements, arguments-differ
self, pk: int
) -> Response:
Expand Down Expand Up @@ -245,6 +251,7 @@ def put( # pylint: disable=too-many-return-statements, arguments-differ
@expose("/<pk>", methods=["DELETE"])
@protect()
@safe
@statsd_metrics
def delete(self, pk: int) -> Response: # pylint: disable=arguments-differ
"""Deletes a Dashboard
---
Expand Down Expand Up @@ -291,6 +298,7 @@ def delete(self, pk: int) -> Response: # pylint: disable=arguments-differ
@expose("/", methods=["DELETE"])
@protect()
@safe
@statsd_metrics
@rison(get_delete_ids_schema)
def bulk_delete(
self, **kwargs: Any
Expand Down Expand Up @@ -351,6 +359,7 @@ def bulk_delete(
@expose("/export/", methods=["GET"])
@protect()
@safe
@statsd_metrics
@rison(get_export_ids_schema)
def export(self, **kwargs: Any) -> Response:
"""Export dashboards
Expand Down
84 changes: 83 additions & 1 deletion superset/views/base_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,12 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import functools
import logging
from typing import cast, Dict, Set, Tuple, Type, Union
from timeit import default_timer
from typing import Any, cast, Dict, Optional, Set, Tuple, Type, Union

from flask import Response
from flask_appbuilder import ModelRestApi
from flask_appbuilder.api import expose, protect, rison, safe
from flask_appbuilder.models.filters import BaseFilter, Filters
Expand All @@ -33,6 +36,21 @@
}


def statsd_metrics(f):
"""
Handle sending all statsd metrics from the REST API
"""

def wraps(self, *args: Any, **kwargs: Any) -> Response:
start = default_timer()
response = f(self, *args, **kwargs)
stop = default_timer()
self.send_stats_metrics(response, f.__name__, stop - start)
dpgaspar marked this conversation as resolved.
Show resolved Hide resolved
return response

return functools.update_wrapper(wraps, f)


class RelatedFieldFilter:
# data class to specify what filter to use on a /related endpoint
# pylint: disable=too-few-public-methods
Expand Down Expand Up @@ -125,11 +143,74 @@ def _get_related_filter(self, datamodel, column_name: str, value: str) -> Filter
return filters

def incr_stats(self, action: str, func_name: str) -> None:
"""
Proxy function for statsd.incr to impose a key structure for REST API's
:param action: String with an action name eg: error, success
:param func_name: The function name
"""
self.stats_logger.incr(f"{self.__class__.__name__}.{func_name}.{action}")

def timing_stats(self, action: str, func_name: str, value: float) -> None:
"""
Proxy function for statsd.incr to impose a key structure for REST API's
:param action: String with an action name eg: error, success
:param func_name: The function name
:param value: A float with the time it took for the endpoint to execute
"""
self.stats_logger.timing(
f"{self.__class__.__name__}.{func_name}.{action}", value
)

def send_stats_metrics(
self, response: Response, key: str, time_delta: Optional[float] = None
) -> None:
"""
Helper function to handle sending statsd metrics
:param response: flask response object, will evaluate if it was an error
:param key: The function name
:param time_delta: Optional time it took for the endpoint to execute
"""
if 200 <= response.status_code < 400:
self.incr_stats("success", key)
else:
self.incr_stats("error", key)
if time_delta:
self.timing_stats("time", key, time_delta)

def info_headless(self, **kwargs) -> Response:
"""
Add statsd metrics to builtin FAB _info endpoint
"""
start = default_timer()
response = super().info_headless(**kwargs)
stop = default_timer()
self.send_stats_metrics(response, self.info.__name__, stop - start)
return response

def get_headless(self, pk, **kwargs) -> Response:
"""
Add statsd metrics to builtin FAB GET endpoint
"""
start = default_timer()
response = super().get_headless(pk, **kwargs)
stop = default_timer()
self.send_stats_metrics(response, self.get.__name__, stop - start)
return response

def get_list_headless(self, **kwargs) -> Response:
"""
Add statsd metrics to builtin FAB GET list endpoint
"""
start = default_timer()
response = super().get_list_headless(**kwargs)
stop = default_timer()
self.send_stats_metrics(response, self.get_list.__name__, stop - start)
return response

@expose("/related/<column_name>", methods=["GET"])
@protect()
@safe
@statsd_metrics
@rison(get_related_schema)
def related(self, column_name: str, **kwargs):
"""Get related fields data
Expand Down Expand Up @@ -182,6 +263,7 @@ def related(self, column_name: str, **kwargs):
$ref: '#/components/responses/500'
"""
if column_name not in self.allowed_rel_fields:
self.incr_stats("error", self.related.__name__)
return self.response_404()
args = kwargs.get("rison", {})
# handle pagination
Expand Down
80 changes: 78 additions & 2 deletions tests/base_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@
"""Unit tests for Superset"""
import imp
import json
from typing import Union
from unittest.mock import Mock
from typing import Union, Dict
from unittest.mock import Mock, patch

import pandas as pd
from flask import Response
from flask_appbuilder.security.sqla import models as ab_models
from flask_testing import TestCase

Expand All @@ -35,6 +36,7 @@
from superset.models.dashboard import Dashboard
from superset.models.datasource_access_request import DatasourceAccessRequest
from superset.utils.core import get_example_database
from superset.views.base_api import BaseSupersetModelRestApi

FAKE_DB_NAME = "fake_db_100"

Expand Down Expand Up @@ -328,3 +330,77 @@ def validate_sql(
def get_dash_by_slug(self, dash_slug):
sesh = db.session()
return sesh.query(Dashboard).filter_by(slug=dash_slug).first()

def get_assert_metric(self, uri: str, func_name: str) -> Response:
"""
Simple client get with an extra assertion for statsd metrics
:param uri: The URI to use for the HTTP GET
:param func_name: The function name that the HTTP GET triggers
for the statsd metric assertion
:return: HTTP Response
"""
with patch.object(
BaseSupersetModelRestApi, "incr_stats", return_value=None
) as mock_method:
rv = self.client.get(uri)
if 200 <= rv.status_code < 400:
mock_method.assert_called_once_with("success", func_name)
else:
mock_method.assert_called_once_with("error", func_name)
return rv

def delete_assert_metric(self, uri: str, func_name: str) -> Response:
"""
Simple client delete with an extra assertion for statsd metrics
:param uri: The URI to use for the HTTP DELETE
:param func_name: The function name that the HTTP DELETE triggers
for the statsd metric assertion
:return: HTTP Response
"""
with patch.object(
BaseSupersetModelRestApi, "incr_stats", return_value=None
) as mock_method:
rv = self.client.delete(uri)
if 200 <= rv.status_code < 400:
mock_method.assert_called_once_with("success", func_name)
else:
mock_method.assert_called_once_with("error", func_name)
return rv

def post_assert_metric(self, uri: str, data: Dict, func_name: str) -> Response:
"""
Simple client post with an extra assertion for statsd metrics
:param uri: The URI to use for the HTTP POST
:param data: The JSON data payload to be posted
:param func_name: The function name that the HTTP POST triggers
for the statsd metric assertion
:return: HTTP Response
"""
with patch.object(
BaseSupersetModelRestApi, "incr_stats", return_value=None
) as mock_method:
rv = self.client.post(uri, json=data)
if 200 <= rv.status_code < 400:
mock_method.assert_called_once_with("success", func_name)
else:
mock_method.assert_called_once_with("error", func_name)
return rv

def put_assert_metric(self, uri: str, data: Dict, func_name: str) -> Response:
"""
Simple client put with an extra assertion for statsd metrics
:param uri: The URI to use for the HTTP PUT
:param data: The JSON data payload to be posted
:param func_name: The function name that the HTTP PUT triggers
for the statsd metric assertion
:return: HTTP Response
"""
with patch.object(
BaseSupersetModelRestApi, "incr_stats", return_value=None
) as mock_method:
rv = self.client.put(uri, json=data)
if 200 <= rv.status_code < 400:
mock_method.assert_called_once_with("success", func_name)
else:
mock_method.assert_called_once_with("error", func_name)
return rv
31 changes: 20 additions & 11 deletions tests/dashboards/api_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ def test_get_dashboard(self):
dashboard = self.insert_dashboard("title", "slug1", [admin.id])
self.login(username="admin")
uri = f"api/v1/dashboard/{dashboard.id}"
rv = self.client.get(uri)
rv = self.get_assert_metric(uri, "get")
self.assertEqual(rv.status_code, 200)
expected_result = {
"changed_by": None,
Expand Down Expand Up @@ -120,14 +120,23 @@ def test_get_dashboard(self):
db.session.delete(dashboard)
db.session.commit()

def test_info_dashboard(self):
"""
Dashboard API: Test info
"""
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still one to go

Copy link
Member Author

@dpgaspar dpgaspar Apr 15, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not one, a bunch of them :) fixed all docs string for dashboards

self.login(username="admin")
uri = f"api/v1/dashboard/_info"
rv = self.get_assert_metric(uri, "info")
self.assertEqual(rv.status_code, 200)

def test_get_dashboard_not_found(self):
"""
Dashboard API: Test get dashboard not found
"""
max_id = db.session.query(func.max(Dashboard.id)).scalar()
self.login(username="admin")
uri = f"api/v1/dashboard/{max_id + 1}"
rv = self.client.get(uri)
rv = self.get_assert_metric(uri, "get")
self.assertEqual(rv.status_code, 404)

def test_get_dashboard_no_data_access(self):
Expand Down Expand Up @@ -159,7 +168,8 @@ def test_get_dashboards_filter(self):
"filters": [{"col": "dashboard_title", "opr": "sw", "value": "ti"}]
}
uri = f"api/v1/dashboard/?q={prison.dumps(arguments)}"
rv = self.client.get(uri)

rv = self.get_assert_metric(uri, "get_list")
self.assertEqual(rv.status_code, 200)
data = json.loads(rv.data.decode("utf-8"))
self.assertEqual(data["count"], 1)
Expand Down Expand Up @@ -258,7 +268,7 @@ def test_delete_dashboard(self):
dashboard_id = self.insert_dashboard("title", "slug1", [admin_id]).id
self.login(username="admin")
uri = f"api/v1/dashboard/{dashboard_id}"
rv = self.client.delete(uri)
rv = self.delete_assert_metric(uri, "delete")
self.assertEqual(rv.status_code, 200)
model = db.session.query(Dashboard).get(dashboard_id)
self.assertEqual(model, None)
Expand All @@ -281,7 +291,7 @@ def test_delete_bulk_dashboards(self):
self.login(username="admin")
argument = dashboard_ids
uri = f"api/v1/dashboard/?q={prison.dumps(argument)}"
rv = self.client.delete(uri)
rv = self.delete_assert_metric(uri, "bulk_delete")
self.assertEqual(rv.status_code, 200)
response = json.loads(rv.data.decode("utf-8"))
expected_response = {"message": f"Deleted {dashboard_count} dashboards"}
Expand Down Expand Up @@ -468,7 +478,7 @@ def test_create_dashboard(self):
}
self.login(username="admin")
uri = "api/v1/dashboard/"
rv = self.client.post(uri, json=dashboard_data)
rv = self.post_assert_metric(uri, dashboard_data, "post")
self.assertEqual(rv.status_code, 201)
data = json.loads(rv.data.decode("utf-8"))
model = db.session.query(Dashboard).get(data.get("id"))
Expand Down Expand Up @@ -520,7 +530,7 @@ def test_create_dashboard_validate_title(self):
dashboard_data = {"dashboard_title": "a" * 600}
self.login(username="admin")
uri = "api/v1/dashboard/"
rv = self.client.post(uri, json=dashboard_data)
rv = self.post_assert_metric(uri, dashboard_data, "post")
self.assertEqual(rv.status_code, 400)
response = json.loads(rv.data.decode("utf-8"))
expected_response = {
Expand Down Expand Up @@ -603,7 +613,7 @@ def test_update_dashboard(self):
dashboard_id = self.insert_dashboard("title1", "slug1", [admin.id]).id
self.login(username="admin")
uri = f"api/v1/dashboard/{dashboard_id}"
rv = self.client.put(uri, json=self.dashboard_data)
rv = self.put_assert_metric(uri, self.dashboard_data, "put")
self.assertEqual(rv.status_code, 200)
model = db.session.query(Dashboard).get(dashboard_id)
self.assertEqual(model.dashboard_title, self.dashboard_data["dashboard_title"])
Expand Down Expand Up @@ -801,7 +811,7 @@ def test_update_dashboard_not_owned(self):
self.login(username="alpha2", password="password")
dashboard_data = {"dashboard_title": "title1_changed", "slug": "slug1 changed"}
uri = f"api/v1/dashboard/{dashboard.id}"
rv = self.client.put(uri, json=dashboard_data)
rv = self.put_assert_metric(uri, dashboard_data, "put")
self.assertEqual(rv.status_code, 403)
db.session.delete(dashboard)
db.session.delete(user_alpha1)
Expand All @@ -815,8 +825,7 @@ def test_export(self):
self.login(username="admin")
argument = [1, 2]
uri = f"api/v1/dashboard/export/?q={prison.dumps(argument)}"

rv = self.client.get(uri)
rv = self.get_assert_metric(uri, "export")
self.assertEqual(rv.status_code, 200)
self.assertEqual(
rv.headers["Content-Disposition"],
Expand Down