Repository: googleapis/google-cloud-python

Issue:
Logging: payload should be parsed as optional
google-cloud-python requires a valid value for the `payload` field in a log entry.

However, the proto shows its optional
 - [https://github.com/googleapis/googleapis/blob/master/google/logging/v2/log_entry.proto#L68](https://github.com/googleapis/googleapis/blob/master/google/logging/v2/log_entry.proto#L68)

and i've verified i can emit a log entry w/ `payload=None` and simply the http_request value (this is used in logs groups)


Looks like its checked for a value here

* https://github.com/GoogleCloudPlatform/google-cloud-python/blob/master/logging/google/cloud/logging/entries.py#L128

```python
        if logger is None:
            logger_name = logger_name_from_path(logger_fullname)
            logger = loggers[logger_fullname] = client.logger(logger_name)
        payload = resource[cls._PAYLOAD_KEY]
        insert_id = resource.get('insertId')
        timestamp = resource.get('timestamp')
```

* https://github.com/GoogleCloudPlatform/google-cloud-python/blob/master/logging/google/cloud/logging/_helpers.py#L47

```python
    if 'textPayload' in resource:
        return TextEntry.from_api_repr(resource, client, loggers)
    elif 'jsonPayload' in resource:
        return StructEntry.from_api_repr(resource, client, loggers)
    elif 'protoPayload' in resource:
        return ProtobufEntry.from_api_repr(resource, client, loggers)
    raise ValueError('Cannot parse log entry resource.')
```



Discussion:

Resolving patch:
diff --git a/logging/google/cloud/logging/_helpers.py b/logging/google/cloud/logging/_helpers.py
--- a/logging/google/cloud/logging/_helpers.py
+++ b/logging/google/cloud/logging/_helpers.py
@@ -16,6 +16,7 @@
 
 import requests
 
+from google.cloud.logging.entries import EmptyEntry
 from google.cloud.logging.entries import ProtobufEntry
 from google.cloud.logging.entries import StructEntry
 from google.cloud.logging.entries import TextEntry
@@ -46,12 +47,14 @@ def entry_from_resource(resource, client, loggers):
     """
     if 'textPayload' in resource:
         return TextEntry.from_api_repr(resource, client, loggers)
-    elif 'jsonPayload' in resource:
+
+    if 'jsonPayload' in resource:
         return StructEntry.from_api_repr(resource, client, loggers)
-    elif 'protoPayload' in resource:
+
+    if 'protoPayload' in resource:
         return ProtobufEntry.from_api_repr(resource, client, loggers)
 
-    raise ValueError('Cannot parse log entry resource.')
+    return EmptyEntry.from_api_repr(resource, client, loggers)
 
 
 def retrieve_metadata_server(metadata_key):
diff --git a/logging/google/cloud/logging/entries.py b/logging/google/cloud/logging/entries.py
--- a/logging/google/cloud/logging/entries.py
+++ b/logging/google/cloud/logging/entries.py
@@ -84,6 +84,7 @@ class _BaseEntry(object):
     :param span_id: (optional) span_id within the trace for the log entry.
                     Specify the trace parameter if span_id is set.
     """
+
     def __init__(self, payload, logger, insert_id=None, timestamp=None,
                  labels=None, severity=None, http_request=None, resource=None,
                  trace=None, span_id=None):
@@ -125,7 +126,10 @@ def from_api_repr(cls, resource, client, loggers=None):
         if logger is None:
             logger_name = logger_name_from_path(logger_fullname)
             logger = loggers[logger_fullname] = client.logger(logger_name)
-        payload = resource[cls._PAYLOAD_KEY]
+        if cls._PAYLOAD_KEY is not None:
+            payload = resource[cls._PAYLOAD_KEY]
+        else:
+            payload = None
         insert_id = resource.get('insertId')
         timestamp = resource.get('timestamp')
         if timestamp is not None:
@@ -146,6 +150,15 @@ def from_api_repr(cls, resource, client, loggers=None):
                    resource=monitored_resource, trace=trace, span_id=span_id)
 
 
+class EmptyEntry(_BaseEntry):
+    """Entry created with no payload.
+
+    See
+    https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry
+    """
+    _PAYLOAD_KEY = None
+
+
 class TextEntry(_BaseEntry):
     """Entry created with ``textPayload``.
 
diff --git a/logging/google/cloud/logging/logger.py b/logging/google/cloud/logging/logger.py
--- a/logging/google/cloud/logging/logger.py
+++ b/logging/google/cloud/logging/logger.py
@@ -188,6 +188,53 @@ def _make_entry_resource(self, text=None, info=None, message=None,
 
         return entry
 
+    def log_empty(self, client=None, labels=None, insert_id=None,
+                  severity=None, http_request=None, timestamp=None,
+                  resource=_GLOBAL_RESOURCE, trace=None, span_id=None):
+        """API call:  log an empty message via a POST request
+
+        See
+        https://cloud.google.com/logging/docs/reference/v2/rest/v2/entries/write
+
+        :type client: :class:`~google.cloud.logging.client.Client` or
+                      ``NoneType``
+        :param client: the client to use.  If not passed, falls back to the
+                       ``client`` stored on the current logger.
+
+        :type labels: dict
+        :param labels: (optional) mapping of labels for the entry.
+
+        :type insert_id: str
+        :param insert_id: (optional) unique ID for log entry.
+
+        :type severity: str
+        :param severity: (optional) severity of event being logged.
+
+        :type http_request: dict
+        :param http_request: (optional) info about HTTP request associated with
+                             the entry
+
+        :type timestamp: :class:`datetime.datetime`
+        :param timestamp: (optional) timestamp of event being logged.
+
+        :type resource: :class:`~google.cloud.logging.resource.Resource`
+        :param resource: Monitored resource of the entry, defaults
+                         to the global resource type.
+
+        :type trace: str
+        :param trace: (optional) traceid to apply to the entry.
+
+        :type span_id: str
+        :param span_id: (optional) span_id within the trace for the log entry.
+                        Specify the trace parameter if span_id is set.
+        """
+        client = self._require_client(client)
+        entry_resource = self._make_entry_resource(
+            labels=labels, insert_id=insert_id, severity=severity,
+            http_request=http_request, timestamp=timestamp, resource=resource,
+            trace=trace, span_id=span_id)
+        client.logging_api.write_entries([entry_resource])
+
     def log_text(self, text, client=None, labels=None, insert_id=None,
                  severity=None, http_request=None, timestamp=None,
                  resource=_GLOBAL_RESOURCE, trace=None, span_id=None):


Test patch:
diff --git a/logging/tests/unit/test__helpers.py b/logging/tests/unit/test__helpers.py
--- a/logging/tests/unit/test__helpers.py
+++ b/logging/tests/unit/test__helpers.py
@@ -26,14 +26,12 @@ def _call_fut(resource, client, loggers):
 
         return entry_from_resource(resource, client, loggers)
 
-    def test_unknown_type(self):
-        with self.assertRaises(ValueError):
-            self._call_fut({}, None, {})
-
     def _payload_helper(self, key, class_name):
         import mock
 
-        resource = {key: 'yup'}
+        resource = {}
+        if key is not None:
+            resource[key] = 'yup'
         client = object()
         loggers = {}
         mock_class = EntryMock()
@@ -45,6 +43,9 @@ def _payload_helper(self, key, class_name):
         self.assertIs(result, mock_class.sentinel)
         self.assertEqual(mock_class.called, (resource, client, loggers))
 
+    def test_wo_payload(self):
+        self._payload_helper(None, 'EmptyEntry')
+
     def test_text_payload(self):
         self._payload_helper('textPayload', 'TextEntry')
 
diff --git a/logging/tests/unit/test_entries.py b/logging/tests/unit/test_entries.py
--- a/logging/tests/unit/test_entries.py
+++ b/logging/tests/unit/test_entries.py
@@ -45,11 +45,11 @@ class Test_BaseEntry(unittest.TestCase):
     LOGGER_NAME = 'LOGGER_NAME'
 
     @staticmethod
-    def _get_target_class():
+    def _get_target_class(payload_key='dummyPayload'):
         from google.cloud.logging.entries import _BaseEntry
 
         class _Dummy(_BaseEntry):
-            _PAYLOAD_KEY = 'dummyPayload'
+            _PAYLOAD_KEY = payload_key
 
         return _Dummy
 
@@ -115,17 +115,15 @@ def test_ctor_explicit(self):
         self.assertEqual(entry.trace, TRACE)
         self.assertEqual(entry.span_id, SPANID)
 
-    def test_from_api_repr_missing_data_no_loggers(self):
+    def test_from_api_repr_no_payload_missing_data_no_loggers(self):
         client = _Client(self.PROJECT)
-        PAYLOAD = 'PAYLOAD'
         LOG_NAME = 'projects/%s/logs/%s' % (self.PROJECT, self.LOGGER_NAME)
         API_REPR = {
-            'dummyPayload': PAYLOAD,
             'logName': LOG_NAME,
         }
-        klass = self._get_target_class()
+        klass = self._get_target_class(payload_key=None)
         entry = klass.from_api_repr(API_REPR, client)
-        self.assertEqual(entry.payload, PAYLOAD)
+        self.assertIsNone(entry.payload)
         self.assertIsNone(entry.insert_id)
         self.assertIsNone(entry.timestamp)
         self.assertIsNone(entry.severity)
diff --git a/logging/tests/unit/test_logger.py b/logging/tests/unit/test_logger.py
--- a/logging/tests/unit/test_logger.py
+++ b/logging/tests/unit/test_logger.py
@@ -88,6 +88,69 @@ def test_batch_w_alternate_client(self):
         self.assertIs(batch.logger, logger)
         self.assertIs(batch.client, client2)
 
+    def test_log_empty_w_default_labels(self):
+        DEFAULT_LABELS = {'foo': 'spam'}
+        ENTRIES = [{
+            'logName': 'projects/%s/logs/%s' % (
+                self.PROJECT, self.LOGGER_NAME),
+            'resource': {
+                'type': 'global',
+                'labels': {},
+            },
+            'labels': DEFAULT_LABELS,
+        }]
+        client = _Client(self.PROJECT)
+        api = client.logging_api = _DummyLoggingAPI()
+        logger = self._make_one(self.LOGGER_NAME, client=client,
+                                labels=DEFAULT_LABELS)
+
+        logger.log_empty()
+
+        self.assertEqual(api._write_entries_called_with,
+                         (ENTRIES, None, None, None))
+
+    def test_log_empty_w_explicit_client_labels_severity_httpreq(self):
+        DEFAULT_LABELS = {'foo': 'spam'}
+        LABELS = {'foo': 'bar', 'baz': 'qux'}
+        IID = 'IID'
+        SEVERITY = 'CRITICAL'
+        METHOD = 'POST'
+        URI = 'https://api.example.com/endpoint'
+        STATUS = '500'
+        TRACE = '12345678-1234-5678-1234-567812345678'
+        SPANID = '000000000000004a'
+        REQUEST = {
+            'requestMethod': METHOD,
+            'requestUrl': URI,
+            'status': STATUS,
+        }
+        ENTRIES = [{
+            'logName': 'projects/%s/logs/%s' % (
+                self.PROJECT, self.LOGGER_NAME),
+            'resource': {
+                'type': 'global',
+                'labels': {},
+            },
+            'labels': LABELS,
+            'insertId': IID,
+            'severity': SEVERITY,
+            'httpRequest': REQUEST,
+            'trace': TRACE,
+            'spanId': SPANID
+        }]
+        client1 = _Client(self.PROJECT)
+        client2 = _Client(self.PROJECT)
+        api = client2.logging_api = _DummyLoggingAPI()
+        logger = self._make_one(self.LOGGER_NAME, client=client1,
+                                labels=DEFAULT_LABELS)
+
+        logger.log_empty(client=client2, labels=LABELS,
+                         insert_id=IID, severity=SEVERITY,
+                         http_request=REQUEST, trace=TRACE, span_id=SPANID)
+
+        self.assertEqual(api._write_entries_called_with,
+                         (ENTRIES, None, None, None))
+
     def test_log_text_w_str_implicit_client(self):
         TEXT = 'TEXT'
         ENTRIES = [{