Skip to content

Commit e65bdf6

Browse files
feat: allow custom labels with standard library logging (#264)
1 parent 8940d6e commit e65bdf6

12 files changed

Lines changed: 196 additions & 113 deletions

File tree

packages/google-cloud-logging/google/cloud/logging_v2/client.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -362,9 +362,9 @@ def get_default_handler(self, **kw):
362362
):
363363
# Cloud Functions with runtimes > 3.8 supports structured logs on standard out
364364
# 3.7 should use the standard CloudLoggingHandler, which sends logs over the network.
365-
return StructuredLogHandler(**kw, project=self.project)
365+
return StructuredLogHandler(**kw, project_id=self.project)
366366
elif monitored_resource.type == _RUN_RESOURCE_TYPE:
367-
return StructuredLogHandler(**kw, project=self.project)
367+
return StructuredLogHandler(**kw, project_id=self.project)
368368
return CloudLoggingHandler(self, resource=monitored_resource, **kw)
369369

370370
def setup_logging(

packages/google-cloud-logging/google/cloud/logging_v2/handlers/handlers.py

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,9 @@ class CloudLoggingFilter(logging.Filter):
3737
the `extras` argument when writing logs.
3838
"""
3939

40-
def __init__(self, project=None):
40+
def __init__(self, project=None, default_labels=None):
4141
self.project = project
42+
self.default_labels = default_labels if default_labels else {}
4243

4344
def filter(self, record):
4445
# ensure record has all required fields set
@@ -61,6 +62,12 @@ def filter(self, record):
6162
inferred_http, inferred_trace = get_request_data()
6263
if inferred_trace is not None and self.project is not None:
6364
inferred_trace = f"projects/{self.project}/traces/{inferred_trace}"
65+
# set labels
66+
user_labels = getattr(record, "labels", {})
67+
record.total_labels = {**self.default_labels, **user_labels}
68+
record.total_labels_str = ", ".join(
69+
[f'"{k}": "{v}"' for k, v in record.total_labels.items()]
70+
)
6471

6572
record.trace = getattr(record, "trace", inferred_trace) or ""
6673
record.http_request = getattr(record, "http_request", inferred_http) or {}
@@ -126,8 +133,7 @@ def __init__(
126133
option is :class:`.SyncTransport`.
127134
resource (~logging_v2.resource.Resource):
128135
Resource for this Handler. Defaults to ``global``.
129-
labels (Optional[dict]): Monitored resource of the entry, defaults
130-
to the global resource type.
136+
labels (Optional[dict]): Additional labels to attach to logs.
131137
stream (Optional[IO]): Stream to be used by the handler.
132138
"""
133139
super(CloudLoggingHandler, self).__init__(stream)
@@ -138,7 +144,8 @@ def __init__(
138144
self.resource = resource
139145
self.labels = labels
140146
# add extra keys to log record
141-
self.addFilter(CloudLoggingFilter(self.project_id))
147+
log_filter = CloudLoggingFilter(project=self.project_id, default_labels=labels)
148+
self.addFilter(log_filter)
142149

143150
def emit(self, record):
144151
"""Actually log the specified logging record.
@@ -151,22 +158,16 @@ def emit(self, record):
151158
record (logging.LogRecord): The record to be logged.
152159
"""
153160
message = super(CloudLoggingHandler, self).format(record)
154-
user_labels = getattr(record, "labels", {})
155-
# merge labels
156-
total_labels = self.labels if self.labels is not None else {}
157-
total_labels.update(user_labels)
158-
if len(total_labels) == 0:
159-
total_labels = None
160161
# send off request
161162
self.transport.send(
162163
record,
163164
message,
164165
resource=getattr(record, "resource", self.resource),
165-
labels=total_labels,
166-
trace=getattr(record, "trace", None),
167-
span_id=getattr(record, "span_id", None),
168-
http_request=getattr(record, "http_request", None),
169-
source_location=getattr(record, "source_location", None),
166+
labels=getattr(record, "total_labels", None) or None,
167+
trace=getattr(record, "trace", None) or None,
168+
span_id=getattr(record, "span_id", None) or None,
169+
http_request=getattr(record, "http_request", None) or None,
170+
source_location=getattr(record, "source_location", None) or None,
170171
)
171172

172173

packages/google-cloud-logging/google/cloud/logging_v2/handlers/structured_log.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,26 +19,34 @@
1919

2020
from google.cloud.logging_v2.handlers.handlers import CloudLoggingFilter
2121

22-
GCP_FORMAT = '{"message": "%(message)s", "severity": "%(levelname)s", "logging.googleapis.com/trace": "%(trace)s", "logging.googleapis.com/sourceLocation": { "file": "%(file)s", "line": "%(line)d", "function": "%(function)s"}, "httpRequest": {"requestMethod": "%(request_method)s", "requestUrl": "%(request_url)s", "userAgent": "%(user_agent)s", "protocol": "%(protocol)s"} }'
22+
GCP_FORMAT = (
23+
'{"message": "%(message)s", '
24+
'"severity": "%(levelname)s", '
25+
'"logging.googleapis.com/labels": { %(total_labels_str)s }, '
26+
'"logging.googleapis.com/trace": "%(trace)s", '
27+
'"logging.googleapis.com/sourceLocation": { "file": "%(file)s", "line": "%(line)d", "function": "%(function)s"}, '
28+
'"httpRequest": {"requestMethod": "%(request_method)s", "requestUrl": "%(request_url)s", "userAgent": "%(user_agent)s", "protocol": "%(protocol)s"} }'
29+
)
2330

2431

2532
class StructuredLogHandler(logging.StreamHandler):
2633
"""Handler to format logs into the Cloud Logging structured log format,
2734
and write them to standard output
2835
"""
2936

30-
def __init__(self, *, name=None, stream=None, project=None):
37+
def __init__(self, *, labels=None, stream=None, project_id=None):
3138
"""
3239
Args:
33-
name (Optional[str]): The name of the custom log in Cloud Logging.
40+
labels (Optional[dict]): Additional labels to attach to logs.
3441
stream (Optional[IO]): Stream to be used by the handler.
42+
project (Optional[str]): Project Id associated with the logs.
3543
"""
3644
super(StructuredLogHandler, self).__init__(stream=stream)
37-
self.name = name
38-
self.project_id = project
45+
self.project_id = project_id
3946

4047
# add extra keys to log record
41-
self.addFilter(CloudLoggingFilter(project))
48+
log_filter = CloudLoggingFilter(project=project_id, default_labels=labels)
49+
self.addFilter(log_filter)
4250

4351
# make logs appear in GCP structured logging format
4452
self.formatter = logging.Formatter(GCP_FORMAT)

packages/google-cloud-logging/tests/environment/deployable/python/snippets.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,21 +43,30 @@ def pylogging(log_text="pylogging", severity="WARNING", **kwargs):
4343
# allowed severity: debug, info, warning, error, critical
4444

4545
# build http request if fields given as argument
46-
http_keys = ["protocol", "requestUrl", "userAgent", "requestMethod"]
46+
http_keys = ["protocol", "requestUrl", "userAgent", "requestMethod"]
4747
if any([k in kwargs for k in http_keys]):
4848
http_request = {}
4949
for key in http_keys:
5050
if key in kwargs:
5151
http_request[key] = kwargs[key]
52-
kwargs['http_request'] = http_request
52+
kwargs["http_request"] = http_request
5353
# build source location if given as argument
54-
source_keys = ["line", "file", "function"]
54+
source_keys = ["line", "file", "function"]
5555
if any([k in kwargs for k in http_keys]):
5656
source_location = {}
5757
for key in source_keys:
5858
if key in kwargs:
5959
source_location[key] = kwargs[key]
60-
kwargs['source_location'] = source_location
60+
kwargs["source_location"] = source_location
61+
# build custom labels
62+
label_prefix = "label_"
63+
label_keys = [k for k in kwargs.keys() if k.startswith(label_prefix)]
64+
if label_keys:
65+
labels = {}
66+
for k in label_keys:
67+
adjusted_key = k[len(label_prefix) :]
68+
labels[adjusted_key] = kwargs[k]
69+
kwargs["labels"] = labels
6170

6271
severity = severity.upper()
6372
if severity == "DEBUG":
@@ -71,13 +80,24 @@ def pylogging(log_text="pylogging", severity="WARNING", **kwargs):
7180
else:
7281
logging.critical(log_text, extra=kwargs)
7382

74-
def pylogging_flask(log_text="pylogging_flask", path="/", base_url="http://google", agent="Chrome", trace="123", **kwargs):
83+
84+
def pylogging_flask(
85+
log_text="pylogging_flask",
86+
path="/",
87+
base_url="http://google",
88+
agent="Chrome",
89+
trace="123",
90+
**kwargs,
91+
):
7592
import flask
93+
7694
app = flask.Flask(__name__)
7795
with app.test_request_context(
78-
path, base_url, headers={'User-Agent': agent, "X_CLOUD_TRACE_CONTEXT": trace}):
96+
path, base_url, headers={"User-Agent": agent, "X_CLOUD_TRACE_CONTEXT": trace}
97+
):
7998
logging.info(log_text)
8099

100+
81101
def print_handlers(**kwargs):
82102
root_logger = logging.getLogger()
83103
handlers_str = ", ".join([type(h).__name__ for h in root_logger.handlers])

packages/google-cloud-logging/tests/environment/tests/common/common.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,9 @@ def test_severity(self):
170170
"DEBUG",
171171
]
172172
for severity in severities:
173-
log_list = self.trigger_and_retrieve(log_text, "simplelog", severity=severity)
173+
log_list = self.trigger_and_retrieve(
174+
log_text, "simplelog", severity=severity
175+
)
174176
found_severity = log_list[-1].severity
175177
self.assertEqual(found_severity.lower(), severity.lower())
176178
# DEFAULT severity should result in empty field

packages/google-cloud-logging/tests/environment/tests/common/python.py

Lines changed: 71 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -72,12 +72,12 @@ def test_source_location_pylogging(self):
7272
found_source = log_list[-1].source_location
7373

7474
self.assertIsNotNone(found_source)
75-
self.assertIsNotNone(found_source['file'])
76-
self.assertIsNotNone(found_source['function'])
77-
self.assertIsNotNone(found_source['line'])
78-
self.assertIn("snippets.py", found_source['file'])
79-
self.assertEqual(found_source['function'], "pylogging")
80-
self.assertTrue(int(found_source['line']) > 0)
75+
self.assertIsNotNone(found_source["file"])
76+
self.assertIsNotNone(found_source["function"])
77+
self.assertIsNotNone(found_source["line"])
78+
self.assertIn("snippets.py", found_source["file"])
79+
self.assertEqual(found_source["function"], "pylogging")
80+
self.assertTrue(int(found_source["line"]) > 0)
8181

8282
def test_flask_http_request_pylogging(self):
8383
if self.environment == "kubernetes" or "appengine" in self.environment:
@@ -91,19 +91,25 @@ def test_flask_http_request_pylogging(self):
9191
expected_path = "/pylogging"
9292
expected_trace = "123"
9393

94-
log_list = self.trigger_and_retrieve(log_text, "pylogging_flask",
95-
path=expected_path, trace=expected_trace, base_url=expected_base_url, agent=expected_agent)
94+
log_list = self.trigger_and_retrieve(
95+
log_text,
96+
"pylogging_flask",
97+
path=expected_path,
98+
trace=expected_trace,
99+
base_url=expected_base_url,
100+
agent=expected_agent,
101+
)
96102
found_request = log_list[-1].http_request
97103

98104
self.assertIsNotNone(found_request)
99-
self.assertIsNotNone(found_request['requestMethod'])
100-
self.assertIsNotNone(found_request['requestUrl'])
101-
self.assertIsNotNone(found_request['userAgent'])
102-
self.assertIsNotNone(found_request['protocol'])
103-
self.assertEqual(found_request['requestMethod'], 'GET')
104-
self.assertEqual(found_request['requestUrl'], expected_base_url + expected_path)
105-
self.assertEqual(found_request['userAgent'], expected_agent)
106-
self.assertEqual(found_request['protocol'], 'HTTP/1.1')
105+
self.assertIsNotNone(found_request["requestMethod"])
106+
self.assertIsNotNone(found_request["requestUrl"])
107+
self.assertIsNotNone(found_request["userAgent"])
108+
self.assertIsNotNone(found_request["protocol"])
109+
self.assertEqual(found_request["requestMethod"], "GET")
110+
self.assertEqual(found_request["requestUrl"], expected_base_url + expected_path)
111+
self.assertEqual(found_request["userAgent"], expected_agent)
112+
self.assertEqual(found_request["protocol"], "HTTP/1.1")
107113

108114
found_trace = log_list[-1].trace
109115
self.assertIsNotNone(found_trace)
@@ -116,36 +122,50 @@ def test_pylogging_extras(self):
116122
return
117123
log_text = f"{inspect.currentframe().f_code.co_name}"
118124
kwargs = {
119-
'trace': '123',
120-
'requestMethod': 'POST',
121-
'requestUrl': 'http://test',
122-
'userAgent': 'agent',
123-
'protocol': 'test',
124-
'line': 25,
125-
'file': 'test-file',
126-
'function': 'test-function'
125+
"trace": "123",
126+
"requestMethod": "POST",
127+
"requestUrl": "http://test",
128+
"userAgent": "agent",
129+
"protocol": "test",
130+
"line": 25,
131+
"file": "test-file",
132+
"function": "test-function",
133+
"label_custom": "test-label",
127134
}
128135
log_list = self.trigger_and_retrieve(log_text, "pylogging", **kwargs)
129136
found_log = log_list[-1]
130137

131138
if self.environment != "functions":
132139
# functions seems to override the user's trace value
133-
self.assertEqual(found_log.trace, kwargs['trace'])
140+
self.assertEqual(found_log.trace, kwargs["trace"])
134141

135142
# check that custom http request fields were set
136143
self.assertIsNotNone(found_log.http_request)
137-
for field in ['requestMethod', 'requestUrl', 'userAgent', 'protocol']:
138-
self.assertIsNotNone(found_log.http_request[field],
139-
'http_request[{field}] is unexpectedly None')
140-
self.assertEqual(found_log.http_request[field], kwargs[field],
141-
f'http_request[{field}] != {kwargs[field]}')
144+
for field in ["requestMethod", "requestUrl", "userAgent", "protocol"]:
145+
self.assertIsNotNone(
146+
found_log.http_request[field],
147+
"http_request[{field}] is unexpectedly None",
148+
)
149+
self.assertEqual(
150+
found_log.http_request[field],
151+
kwargs[field],
152+
f"http_request[{field}] != {kwargs[field]}",
153+
)
142154
# check that custom source location fields were set
143155
self.assertIsNotNone(found_log.source_location)
144-
for field in ['line', 'file', 'function']:
145-
self.assertIsNotNone(found_log.source_location[field],
146-
f'source_location[{field}] is unexpectedly None')
147-
self.assertEqual(found_log.source_location[field], kwargs[field],
148-
f'source_location[{field}] != {kwargs[field]}')
156+
for field in ["line", "file", "function"]:
157+
self.assertIsNotNone(
158+
found_log.source_location[field],
159+
f"source_location[{field}] is unexpectedly None",
160+
)
161+
self.assertEqual(
162+
found_log.source_location[field],
163+
kwargs[field],
164+
f"source_location[{field}] != {kwargs[field]}",
165+
)
166+
# check that custom label is set
167+
self.assertIsNotNone(found_log.labels)
168+
self.assertEqual(found_log.labels["custom"], kwargs["label_custom"])
149169

150170
def test_pylogging_extras_sparse(self):
151171
if self.environment == "kubernetes" or "appengine" in self.environment:
@@ -154,21 +174,27 @@ def test_pylogging_extras_sparse(self):
154174
return
155175
log_text = f"{inspect.currentframe().f_code.co_name}"
156176
kwargs = {
157-
'requestMethod': 'POST',
158-
'file': 'test-file',
177+
"requestMethod": "POST",
178+
"file": "test-file",
159179
}
160180
log_list = self.trigger_and_retrieve(log_text, "pylogging", **kwargs)
161181
found_log = log_list[-1]
162182

163183
# check that custom http request fields were set
164184
self.assertIsNotNone(found_log.http_request)
165-
self.assertEqual(found_log.http_request["requestMethod"], kwargs["requestMethod"])
166-
for field in ['requestUrl', 'userAgent', 'protocol']:
167-
self.assertIsNone(found_log.http_request.get(field, None),
168-
f'http_request[{field}] is unexpectedly not None')
185+
self.assertEqual(
186+
found_log.http_request["requestMethod"], kwargs["requestMethod"]
187+
)
188+
for field in ["requestUrl", "userAgent", "protocol"]:
189+
self.assertIsNone(
190+
found_log.http_request.get(field, None),
191+
f"http_request[{field}] is unexpectedly not None",
192+
)
169193
# check that custom source location fields were set
170194
self.assertIsNotNone(found_log.source_location)
171-
self.assertEqual(found_log.source_location['file'], kwargs['file'])
172-
for field in ['line', 'function']:
173-
self.assertIsNone(found_log.source_location.get(field, None),
174-
f'source_location[{field}] is unexpectedly not None')
195+
self.assertEqual(found_log.source_location["file"], kwargs["file"])
196+
for field in ["line", "function"]:
197+
self.assertIsNone(
198+
found_log.source_location.get(field, None),
199+
f"source_location[{field}] is unexpectedly not None",
200+
)

packages/google-cloud-logging/tests/environment/tests/nodejs/test_cloudrun.py

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,17 +22,18 @@
2222

2323
from ..common.common import Common
2424

25+
2526
class TestCloudRun(Common, unittest.TestCase):
2627

27-
environment = "cloudrun"
28-
language = "nodejs"
28+
environment = "cloudrun"
29+
language = "nodejs"
2930

30-
# What it should be
31-
monitored_resource_name = "cloud_run_revision"
32-
monitored_resource_labels = [
33-
"project_id",
34-
"service_name",
35-
"revision_name",
36-
"location",
37-
"configuration_name",
38-
]
31+
# What it should be
32+
monitored_resource_name = "cloud_run_revision"
33+
monitored_resource_labels = [
34+
"project_id",
35+
"service_name",
36+
"revision_name",
37+
"location",
38+
"configuration_name",
39+
]

0 commit comments

Comments
 (0)