Skip to content

Commit a78f577

Browse files
feat: use standard output logs on serverless environments (#228)
1 parent 79b37c3 commit a78f577

File tree

8 files changed

+431
-14
lines changed

8 files changed

+431
-14
lines changed

google/cloud/logging/handlers/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,16 @@
1616

1717
from google.cloud.logging_v2.handlers.app_engine import AppEngineHandler
1818
from google.cloud.logging_v2.handlers.container_engine import ContainerEngineHandler
19+
from google.cloud.logging_v2.handlers.structured_log import StructuredLogHandler
20+
from google.cloud.logging_v2.handlers.handlers import CloudLoggingFilter
1921
from google.cloud.logging_v2.handlers.handlers import CloudLoggingHandler
2022
from google.cloud.logging_v2.handlers.handlers import setup_logging
2123

2224
__all__ = [
2325
"AppEngineHandler",
26+
"CloudLoggingFilter",
2427
"CloudLoggingHandler",
2528
"ContainerEngineHandler",
29+
"StructuredLogHandler",
2630
"setup_logging",
2731
]

google/cloud/logging_v2/client.py

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
import logging
1818
import os
19+
import sys
1920

2021
try:
2122
from google.cloud.logging_v2 import _gapic
@@ -36,6 +37,7 @@
3637
from google.cloud.logging_v2.handlers import CloudLoggingHandler
3738
from google.cloud.logging_v2.handlers import AppEngineHandler
3839
from google.cloud.logging_v2.handlers import ContainerEngineHandler
40+
from google.cloud.logging_v2.handlers import StructuredLogHandler
3941
from google.cloud.logging_v2.handlers import setup_logging
4042
from google.cloud.logging_v2.handlers.handlers import EXCLUDED_LOGGER_DEFAULTS
4143
from google.cloud.logging_v2.resource import Resource
@@ -53,6 +55,7 @@
5355
_GAE_RESOURCE_TYPE = "gae_app"
5456
_GKE_RESOURCE_TYPE = "k8s_container"
5557
_GCF_RESOURCE_TYPE = "cloud_function"
58+
_RUN_RESOURCE_TYPE = "cloud_run_revision"
5659

5760

5861
class Client(ClientWithProject):
@@ -347,18 +350,22 @@ def get_default_handler(self, **kw):
347350
"""
348351
monitored_resource = kw.pop("resource", detect_resource(self.project))
349352

350-
if (
351-
isinstance(monitored_resource, Resource)
352-
and monitored_resource.type == _GAE_RESOURCE_TYPE
353-
):
354-
return AppEngineHandler(self, **kw)
355-
elif (
356-
isinstance(monitored_resource, Resource)
357-
and monitored_resource.type == _GKE_RESOURCE_TYPE
358-
):
359-
return ContainerEngineHandler(**kw)
360-
else:
361-
return CloudLoggingHandler(self, resource=monitored_resource, **kw)
353+
if isinstance(monitored_resource, Resource):
354+
if monitored_resource.type == _GAE_RESOURCE_TYPE:
355+
return AppEngineHandler(self, **kw)
356+
elif monitored_resource.type == _GKE_RESOURCE_TYPE:
357+
return ContainerEngineHandler(**kw)
358+
elif (
359+
monitored_resource.type == _GCF_RESOURCE_TYPE
360+
and sys.version_info[0] == 3
361+
and sys.version_info[1] >= 8
362+
):
363+
# Cloud Functions with runtimes > 3.8 supports structured logs on standard out
364+
# 3.7 should use the standard CloudLoggingHandler, which sends logs over the network.
365+
return StructuredLogHandler(**kw, project=self.project)
366+
elif monitored_resource.type == _RUN_RESOURCE_TYPE:
367+
return StructuredLogHandler(**kw, project=self.project)
368+
return CloudLoggingHandler(self, resource=monitored_resource, **kw)
362369

363370
def setup_logging(
364371
self, *, log_level=logging.INFO, excluded_loggers=EXCLUDED_LOGGER_DEFAULTS, **kw

google/cloud/logging_v2/handlers/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,16 @@
1616

1717
from google.cloud.logging_v2.handlers.app_engine import AppEngineHandler
1818
from google.cloud.logging_v2.handlers.container_engine import ContainerEngineHandler
19+
from google.cloud.logging_v2.handlers.structured_log import StructuredLogHandler
1920
from google.cloud.logging_v2.handlers.handlers import CloudLoggingHandler
21+
from google.cloud.logging_v2.handlers.handlers import CloudLoggingFilter
2022
from google.cloud.logging_v2.handlers.handlers import setup_logging
2123

2224
__all__ = [
2325
"AppEngineHandler",
26+
"CloudLoggingFilter",
2427
"CloudLoggingHandler",
2528
"ContainerEngineHandler",
29+
"StructuredLogHandler",
2630
"setup_logging",
2731
]

google/cloud/logging_v2/handlers/handlers.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@
1616

1717
import logging
1818

19-
2019
from google.cloud.logging_v2.logger import _GLOBAL_RESOURCE
2120
from google.cloud.logging_v2.handlers.transports import BackgroundThreadTransport
2221
from google.cloud.logging_v2.handlers._monitored_resources import detect_resource
22+
from google.cloud.logging_v2.handlers._helpers import get_request_data
2323

2424
DEFAULT_LOGGER_NAME = "python"
2525

@@ -28,6 +28,38 @@
2828
_CLEAR_HANDLER_RESOURCE_TYPES = ("gae_app", "cloud_function")
2929

3030

31+
class CloudLoggingFilter(logging.Filter):
32+
"""Python standard ``logging`` Filter class to add Cloud Logging
33+
information to each LogRecord.
34+
35+
When attached to a LogHandler, each incoming log will receive trace and
36+
http_request related to the request. This data can be overwritten using
37+
the `extras` argument when writing logs.
38+
"""
39+
40+
def __init__(self, project=None):
41+
self.project = project
42+
43+
def filter(self, record):
44+
# ensure record has all required fields set
45+
record.lineno = 0 if record.lineno is None else record.lineno
46+
record.msg = "" if record.msg is None else record.msg
47+
record.funcName = "" if record.funcName is None else record.funcName
48+
record.pathname = "" if record.pathname is None else record.pathname
49+
# find http request data
50+
inferred_http, inferred_trace = get_request_data()
51+
if inferred_trace is not None and self.project is not None:
52+
inferred_trace = f"projects/{self.project}/traces/{inferred_trace}"
53+
54+
record.trace = getattr(record, "trace", inferred_trace) or ""
55+
record.http_request = getattr(record, "http_request", inferred_http) or {}
56+
record.request_method = record.http_request.get("requestMethod", "")
57+
record.request_url = record.http_request.get("requestUrl", "")
58+
record.user_agent = record.http_request.get("userAgent", "")
59+
record.protocol = record.http_request.get("protocol", "")
60+
return True
61+
62+
3163
class CloudLoggingHandler(logging.StreamHandler):
3264
"""Handler that directly makes Cloud Logging API calls.
3365
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Copyright 2021 Google LLC All Rights Reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Logging handler for printing formatted structured logs to standard output.
16+
"""
17+
18+
import logging.handlers
19+
20+
from google.cloud.logging_v2.handlers.handlers import CloudLoggingFilter
21+
22+
GCP_FORMAT = '{"message": "%(message)s", "severity": "%(levelname)s", "logging.googleapis.com/trace": "%(trace)s", "logging.googleapis.com/sourceLocation": { "file": "%(pathname)s", "line": "%(lineno)d", "function": "%(funcName)s"}, "httpRequest": {"requestMethod": "%(request_method)s", "requestUrl": "%(request_url)s", "userAgent": "%(user_agent)s", "protocol": "%(protocol)s"} }'
23+
24+
25+
class StructuredLogHandler(logging.StreamHandler):
26+
"""Handler to format logs into the Cloud Logging structured log format,
27+
and write them to standard output
28+
"""
29+
30+
def __init__(self, *, name=None, stream=None, project=None):
31+
"""
32+
Args:
33+
name (Optional[str]): The name of the custom log in Cloud Logging.
34+
stream (Optional[IO]): Stream to be used by the handler.
35+
"""
36+
super(StructuredLogHandler, self).__init__(stream=stream)
37+
self.name = name
38+
self.project_id = project
39+
40+
# add extra keys to log record
41+
self.addFilter(CloudLoggingFilter(project))
42+
43+
# make logs appear in GCP structured logging format
44+
self.formatter = logging.Formatter(GCP_FORMAT)
45+
46+
def format(self, record):
47+
"""Format the message into structured log JSON.
48+
Args:
49+
record (logging.LogRecord): The log record.
50+
Returns:
51+
str: A JSON string formatted for GKE fluentd.
52+
"""
53+
54+
payload = self.formatter.format(record)
55+
return payload

tests/unit/handlers/test_handlers.py

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,170 @@
2323
)
2424

2525

26+
class TestCloudLoggingFilter(unittest.TestCase):
27+
28+
PROJECT = "PROJECT"
29+
30+
@staticmethod
31+
def _get_target_class():
32+
from google.cloud.logging.handlers import CloudLoggingFilter
33+
34+
return CloudLoggingFilter
35+
36+
def _make_one(self, *args, **kw):
37+
return self._get_target_class()(*args, **kw)
38+
39+
@staticmethod
40+
def create_app():
41+
import flask
42+
43+
app = flask.Flask(__name__)
44+
45+
@app.route("/")
46+
def index():
47+
return "test flask trace" # pragma: NO COVER
48+
49+
return app
50+
51+
def test_filter_record(self):
52+
"""
53+
test adding fields to a standard record
54+
"""
55+
import logging
56+
57+
filter_obj = self._make_one()
58+
logname = "loggername"
59+
message = "hello world,嗨 世界"
60+
pathname = "testpath"
61+
lineno = 1
62+
func = "test-function"
63+
record = logging.LogRecord(
64+
logname, logging.INFO, pathname, lineno, message, None, None, func=func
65+
)
66+
67+
success = filter_obj.filter(record)
68+
self.assertTrue(success)
69+
70+
self.assertEqual(record.lineno, lineno)
71+
self.assertEqual(record.msg, message)
72+
self.assertEqual(record.funcName, func)
73+
self.assertEqual(record.pathname, pathname)
74+
self.assertEqual(record.trace, "")
75+
self.assertEqual(record.http_request, {})
76+
self.assertEqual(record.request_method, "")
77+
self.assertEqual(record.request_url, "")
78+
self.assertEqual(record.user_agent, "")
79+
self.assertEqual(record.protocol, "")
80+
81+
def test_minimal_record(self):
82+
"""
83+
test filter adds empty strings on missing attributes
84+
"""
85+
import logging
86+
87+
filter_obj = self._make_one()
88+
record = logging.LogRecord(None, logging.INFO, None, None, None, None, None,)
89+
record.created = None
90+
91+
success = filter_obj.filter(record)
92+
self.assertTrue(success)
93+
94+
self.assertEqual(record.lineno, 0)
95+
self.assertEqual(record.msg, "")
96+
self.assertEqual(record.funcName, "")
97+
self.assertEqual(record.pathname, "")
98+
self.assertEqual(record.trace, "")
99+
self.assertEqual(record.http_request, {})
100+
self.assertEqual(record.request_method, "")
101+
self.assertEqual(record.request_url, "")
102+
self.assertEqual(record.user_agent, "")
103+
self.assertEqual(record.protocol, "")
104+
105+
def test_record_with_request(self):
106+
"""
107+
test filter adds http request data when available
108+
"""
109+
import logging
110+
111+
filter_obj = self._make_one()
112+
record = logging.LogRecord(None, logging.INFO, None, None, None, None, None,)
113+
record.created = None
114+
115+
expected_path = "http://testserver/123"
116+
expected_agent = "Mozilla/5.0"
117+
expected_trace = "123"
118+
expected_request = {
119+
"requestMethod": "PUT",
120+
"requestUrl": expected_path,
121+
"userAgent": expected_agent,
122+
"protocol": "HTTP/1.1",
123+
}
124+
125+
app = self.create_app()
126+
with app.test_client() as c:
127+
c.put(
128+
path=expected_path,
129+
data="body",
130+
headers={
131+
"User-Agent": expected_agent,
132+
"X_CLOUD_TRACE_CONTEXT": expected_trace,
133+
},
134+
)
135+
success = filter_obj.filter(record)
136+
self.assertTrue(success)
137+
138+
self.assertEqual(record.trace, expected_trace)
139+
for key, val in expected_request.items():
140+
self.assertEqual(record.http_request[key], val)
141+
self.assertEqual(record.request_method, "PUT")
142+
self.assertEqual(record.request_url, expected_path)
143+
self.assertEqual(record.user_agent, expected_agent)
144+
self.assertEqual(record.protocol, "HTTP/1.1")
145+
146+
def test_user_overrides(self):
147+
"""
148+
ensure user can override fields
149+
"""
150+
import logging
151+
152+
filter_obj = self._make_one()
153+
record = logging.LogRecord(
154+
"name", logging.INFO, "default", 99, "message", None, None, func="default"
155+
)
156+
record.created = 5.03
157+
158+
app = self.create_app()
159+
with app.test_client() as c:
160+
c.put(
161+
path="http://testserver/123",
162+
data="body",
163+
headers={"User-Agent": "default", "X_CLOUD_TRACE_CONTEXT": "default"},
164+
)
165+
# override values
166+
overwritten_trace = "456"
167+
record.trace = overwritten_trace
168+
overwritten_method = "GET"
169+
overwritten_url = "www.google.com"
170+
overwritten_agent = "custom"
171+
overwritten_protocol = "test"
172+
overwritten_request_object = {
173+
"requestMethod": overwritten_method,
174+
"requestUrl": overwritten_url,
175+
"userAgent": overwritten_agent,
176+
"protocol": overwritten_protocol,
177+
}
178+
record.http_request = overwritten_request_object
179+
success = filter_obj.filter(record)
180+
self.assertTrue(success)
181+
182+
self.assertEqual(record.trace, overwritten_trace)
183+
self.assertEqual(record.http_request, overwritten_request_object)
184+
self.assertEqual(record.request_method, overwritten_method)
185+
self.assertEqual(record.request_url, overwritten_url)
186+
self.assertEqual(record.user_agent, overwritten_agent)
187+
self.assertEqual(record.protocol, overwritten_protocol)
188+
189+
26190
class TestCloudLoggingHandler(unittest.TestCase):
27191

28192
PROJECT = "PROJECT"

0 commit comments

Comments
 (0)