Repository: saleor/saleor

Issue:
GraphQL errors shouldn't be logged
### What I'm trying to achieve
Remove useless exception output.

### Steps to reproduce the problem
1. run `{ products { invalid } }`
2. an exception will be logged despite it was handled

### What I expected to happen
It shouldn't be logged. We could check whether the final exception is a GraphQL exception or an actual exception.


Discussion:
This would require overriding or silencing `graphql.execution.utils`'s logger from `ExecutionContext.report_error` to prevent it from sending unwanted logs.
For sure we don't want to log errors like:
- `PermissionDenied`
- `ValidationError`

I don't know what do do with `GraphQLError` - it could be useful if you have broken queries in your client, but it shouldn't be the case with our demo stores that we control and test locally. Maybe we should only silence this error on `demo` branch? 
Also please take a look at: https://docs.sentry.io/error-reporting/configuration/filtering/?platform=python, maybe this would help.
https://github.com/mirumee/saleor/pull/4906#discussion_r339529371

Resolving patch:
diff --git a/saleor/graphql/views.py b/saleor/graphql/views.py
--- a/saleor/graphql/views.py
+++ b/saleor/graphql/views.py
@@ -15,8 +15,10 @@
     format_error as format_graphql_error,
 )
 from graphql.execution import ExecutionResult
+from graphql_jwt.exceptions import PermissionDenied
 
-logger = logging.getLogger(__name__)
+unhandled_errors_logger = logging.getLogger("saleor.graphql.errors.unhandled")
+handled_errors_logger = logging.getLogger("saleor.graphql.errors.handled")
 
 
 class GraphQLView(View):
@@ -34,6 +36,8 @@ class GraphQLView(View):
     middleware = None
     root_value = None
 
+    HANDLED_EXCEPTIONS = (GraphQLError, PermissionDenied)
+
     def __init__(
         self, schema=None, executor=None, middleware=None, root_value=None, backend=None
     ):
@@ -187,8 +191,8 @@ def get_graphql_params(request: HttpRequest, data: dict):
             variables = operations.get("variables")
         return query, variables, operation_name
 
-    @staticmethod
-    def format_error(error):
+    @classmethod
+    def format_error(cls, error):
         if isinstance(error, GraphQLError):
             result = format_graphql_error(error)
         else:
@@ -198,7 +202,10 @@ def format_error(error):
         while isinstance(exc, GraphQLError) and hasattr(exc, "original_error"):
             exc = exc.original_error
 
-        logger.error("Exception information:", exc_info=exc)
+        if isinstance(exc, cls.HANDLED_EXCEPTIONS):
+            handled_errors_logger.error("A query had an error", exc_info=exc)
+        else:
+            unhandled_errors_logger.error("A query failed unexpectedly", exc_info=exc)
 
         result["extensions"] = {"exception": {"code": type(exc).__name__}}
         if settings.DEBUG:
diff --git a/saleor/settings.py b/saleor/settings.py
--- a/saleor/settings.py
+++ b/saleor/settings.py
@@ -331,6 +331,7 @@ def get_bool_from_env(name, default_value):
             "class": "logging.StreamHandler",
             "formatter": "verbose",
         },
+        "null": {"class": "logging.NullHandler"},
     },
     "loggers": {
         "django": {
@@ -340,6 +341,22 @@ def get_bool_from_env(name, default_value):
         },
         "django.server": {"handlers": ["console"], "level": "INFO", "propagate": True},
         "saleor": {"handlers": ["console"], "level": "DEBUG", "propagate": True},
+        "saleor.graphql.errors.handled": {
+            "handlers": ["console"],
+            "level": "ERROR",
+            "propagate": True,
+        },
+        # You can configure this logger to go to another file using a file handler.
+        # Refer to https://docs.djangoproject.com/en/2.2/topics/logging/#examples.
+        # This allow easier filtering from GraphQL query/permission errors that may
+        # have been triggered by your frontend applications from the internal errors
+        # that happen in backend
+        "saleor.graphql.errors.unhandled": {
+            "handlers": ["console"],
+            "level": "ERROR",
+            "propagate": True,
+        },
+        "graphql.execution.utils": {"handlers": ["null"], "propagate": False},
     },
 }
 


Test patch:
diff --git a/tests/api/test_view.py b/tests/api/test_view.py
--- a/tests/api/test_view.py
+++ b/tests/api/test_view.py
@@ -1,7 +1,13 @@
+import logging
+from unittest import mock
+
 import graphene
 import pytest
 from django.test import override_settings
 
+from saleor.graphql.product.types import Product
+from saleor.graphql.views import handled_errors_logger, unhandled_errors_logger
+
 from .conftest import API_PATH
 from .utils import _get_graphql_content_from_response, get_graphql_content
 
@@ -125,3 +131,104 @@ def mocked_execute(*args, **kwargs):
     assert response.status_code == 400
     content = _get_graphql_content_from_response(response)
     assert content["errors"][0]["message"] == "Spanish inquisition"
+
+
+class LoggingHandler(logging.Handler):
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.messages = []
+
+    def emit(self, record: logging.LogRecord):
+        exc_type, exc_value, _tb = record.exc_info
+        self.messages.append(
+            f"{record.name}[{record.levelname.upper()}].{exc_type.__name__}"
+        )
+
+
+@pytest.fixture
+def graphql_log_handler():
+    log_handler = LoggingHandler()
+
+    unhandled_errors_logger.addHandler(log_handler)
+    handled_errors_logger.addHandler(log_handler)
+
+    return log_handler
+
+
+def test_invalid_query_graphql_errors_are_logged_in_another_logger(
+    api_client, graphql_log_handler
+):
+    response = api_client.post_graphql("{ shop }")
+    assert response.status_code == 400
+    assert graphql_log_handler.messages == [
+        "saleor.graphql.errors.handled[ERROR].GraphQLError"
+    ]
+
+
+def test_invalid_syntax_graphql_errors_are_logged_in_another_logger(
+    api_client, graphql_log_handler
+):
+    response = api_client.post_graphql("{ }")
+    assert response.status_code == 400
+    assert graphql_log_handler.messages == [
+        "saleor.graphql.errors.handled[ERROR].GraphQLSyntaxError"
+    ]
+
+
+def test_permission_denied_query_graphql_errors_are_logged_in_another_logger(
+    api_client, graphql_log_handler
+):
+    response = api_client.post_graphql(
+        """
+        mutation {
+          productImageDelete(id: "aa") {
+            errors {
+              message
+            }
+          }
+        }
+        """
+    )
+    assert response.status_code == 200
+    assert graphql_log_handler.messages == [
+        "saleor.graphql.errors.handled[ERROR].PermissionDenied"
+    ]
+
+
+def test_validation_errors_query_do_not_get_logged(
+    staff_api_client, graphql_log_handler, permission_manage_products
+):
+    staff_api_client.user.user_permissions.add(permission_manage_products)
+    response = staff_api_client.post_graphql(
+        """
+        mutation {
+          productImageDelete(id: "aa") {
+            errors {
+              message
+            }
+          }
+        }
+        """
+    )
+    assert response.status_code == 200
+    assert graphql_log_handler.messages == []
+
+
+@mock.patch.object(Product, "get_node")
+def test_unexpected_exceptions_are_logged_in_their_own_logger(
+    mocked_get_node, staff_api_client, graphql_log_handler, permission_manage_products
+):
+    def bad_get_node(info, pk):
+        raise NotImplementedError(info, pk)
+
+    mocked_get_node.side_effect = bad_get_node
+
+    staff_api_client.user.user_permissions.add(permission_manage_products)
+    response = staff_api_client.post_graphql(
+        '{ product(id: "UHJvZHVjdDoxMg==") { name } }'
+    )
+
+    assert response.status_code == 200
+    assert graphql_log_handler.messages == [
+        "saleor.graphql.errors.unhandled[ERROR].NotImplementedError"
+    ]