1# Copyright 2016 Google LLC
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"""Define API Loggers."""
16
17import collections
18import re
19
20from google.cloud.logging_v2._helpers import _add_defaults_to_filter
21from google.cloud.logging_v2.entries import LogEntry
22from google.cloud.logging_v2.entries import ProtobufEntry
23from google.cloud.logging_v2.entries import StructEntry
24from google.cloud.logging_v2.entries import TextEntry
25from google.cloud.logging_v2.resource import Resource
26from google.cloud.logging_v2.handlers._monitored_resources import detect_resource
27from google.cloud.logging_v2._instrumentation import _add_instrumentation
28
29from google.api_core.exceptions import InvalidArgument
30from google.rpc.error_details_pb2 import DebugInfo
31
32import google.cloud.logging_v2
33import google.protobuf.message
34
35_GLOBAL_RESOURCE = Resource(type="global", labels={})
36
37
38_OUTBOUND_ENTRY_FIELDS = ( # (name, default)
39 ("type_", None),
40 ("log_name", None),
41 ("payload", None),
42 ("labels", None),
43 ("insert_id", None),
44 ("severity", None),
45 ("http_request", None),
46 ("timestamp", None),
47 ("resource", _GLOBAL_RESOURCE),
48 ("trace", None),
49 ("span_id", None),
50 ("trace_sampled", None),
51 ("source_location", None),
52)
53
54_STRUCT_EXTRACTABLE_FIELDS = ["severity", "trace", "span_id"]
55
56
57class Logger(object):
58 """Loggers represent named targets for log entries.
59
60 See https://cloud.google.com/logging/docs/reference/v2/rest/v2/projects.logs
61 """
62
63 def __init__(self, name, client, *, labels=None, resource=None):
64 """
65 Args:
66 name (str): The name of the logger.
67 client (~logging_v2.client.Client):
68 A client which holds credentials and project configuration
69 for the logger (which requires a project).
70 resource (Optional[~logging_v2.Resource]): a monitored resource object
71 representing the resource the code was run on. If not given, will
72 be inferred from the environment.
73 labels (Optional[dict]): Mapping of default labels for entries written
74 via this logger.
75
76 """
77 if not resource:
78 # infer the correct monitored resource from the local environment
79 resource = detect_resource(client.project)
80 self.name = name
81 self._client = client
82 self.labels = labels
83 self.default_resource = resource
84
85 @property
86 def client(self):
87 """Clent bound to the logger."""
88 return self._client
89
90 @property
91 def project(self):
92 """Project bound to the logger."""
93 return self._client.project
94
95 @property
96 def full_name(self):
97 """Fully-qualified name used in logging APIs"""
98 return f"projects/{self.project}/logs/{self.name}"
99
100 @property
101 def path(self):
102 """URI path for use in logging APIs"""
103 return f"/{self.full_name}"
104
105 def _require_client(self, client):
106 """Check client or verify over-ride. Also sets ``parent``.
107
108 Args:
109 client (Union[None, ~logging_v2.client.Client]):
110 The client to use. If not passed, falls back to the
111 ``client`` stored on the current sink.
112
113 Returns:
114 ~logging_v2.client.Client: The client passed in
115 or the currently bound client.
116 """
117 if client is None:
118 client = self._client
119 return client
120
121 def batch(self, *, client=None):
122 """Return a batch to use as a context manager.
123
124 Args:
125 client (Union[None, ~logging_v2.client.Client]):
126 The client to use. If not passed, falls back to the
127 ``client`` stored on the current sink.
128
129 Returns:
130 Batch: A batch to use as a context manager.
131 """
132 client = self._require_client(client)
133 return Batch(self, client)
134
135 def _do_log(self, client, _entry_class, payload=None, **kw):
136 """Helper for :meth:`log_empty`, :meth:`log_text`, etc."""
137 client = self._require_client(client)
138
139 # Apply defaults
140 kw["log_name"] = kw.pop("log_name", self.full_name)
141 kw["labels"] = kw.pop("labels", self.labels)
142 kw["resource"] = kw.pop("resource", self.default_resource)
143
144 severity = kw.get("severity", None)
145 if isinstance(severity, str):
146 # convert severity to upper case, as expected by enum definition
147 kw["severity"] = severity.upper()
148
149 if isinstance(kw["resource"], collections.abc.Mapping):
150 # if resource was passed as a dict, attempt to parse it into a
151 # Resource object
152 try:
153 kw["resource"] = Resource(**kw["resource"])
154 except TypeError as e:
155 # dict couldn't be parsed as a Resource
156 raise TypeError("invalid resource dict") from e
157
158 if payload is not None:
159 entry = _entry_class(payload=payload, **kw)
160 else:
161 entry = _entry_class(**kw)
162
163 api_repr = entry.to_api_repr()
164 entries = [api_repr]
165
166 if google.cloud.logging_v2._instrumentation_emitted is False:
167 entries = _add_instrumentation(entries, **kw)
168 google.cloud.logging_v2._instrumentation_emitted = True
169 # partial_success is true to avoid dropping instrumentation logs
170 client.logging_api.write_entries(entries, partial_success=True)
171
172 def log_empty(self, *, client=None, **kw):
173 """Log an empty message
174
175 See
176 https://cloud.google.com/logging/docs/reference/v2/rest/v2/entries/write
177
178 Args:
179 client (Optional[~logging_v2.client.Client]):
180 The client to use. If not passed, falls back to the
181 ``client`` stored on the current sink.
182 kw (Optional[dict]): additional keyword arguments for the entry.
183 See :class:`~logging_v2.entries.LogEntry`.
184 """
185 self._do_log(client, LogEntry, **kw)
186
187 def log_text(self, text, *, client=None, **kw):
188 """Log a text message
189
190 See
191 https://cloud.google.com/logging/docs/reference/v2/rest/v2/entries/write
192
193 Args:
194 text (str): the log message
195 client (Optional[~logging_v2.client.Client]):
196 The client to use. If not passed, falls back to the
197 ``client`` stored on the current sink.
198 kw (Optional[dict]): additional keyword arguments for the entry.
199 See :class:`~logging_v2.entries.LogEntry`.
200 """
201 self._do_log(client, TextEntry, text, **kw)
202
203 def log_struct(self, info, *, client=None, **kw):
204 """Logs a dictionary message.
205
206 See
207 https://cloud.google.com/logging/docs/reference/v2/rest/v2/entries/write
208
209 The message must be able to be serializable to a Protobuf Struct.
210 It must be a dictionary of strings to one of the following:
211
212 - :class:`str`
213 - :class:`int`
214 - :class:`float`
215 - :class:`bool`
216 - :class:`list[str|float|int|bool|list|dict|None]`
217 - :class:`dict[str, str|float|int|bool|list|dict|None]`
218
219 For more details on Protobuf structs, see https://protobuf.dev/reference/protobuf/google.protobuf/#value.
220 If the provided dictionary cannot be serialized into a Protobuf struct,
221 it will not be logged, and a :class:`ValueError` will be raised.
222
223 Args:
224 info (dict[str, str|float|int|bool|list|dict|None]):
225 the log entry information.
226 client (Optional[~logging_v2.client.Client]):
227 The client to use. If not passed, falls back to the
228 ``client`` stored on the current sink.
229 kw (Optional[dict]): additional keyword arguments for the entry.
230 See :class:`~logging_v2.entries.LogEntry`.
231
232 Raises:
233 ValueError:
234 if the dictionary message provided cannot be serialized into a Protobuf
235 struct.
236 """
237 for field in _STRUCT_EXTRACTABLE_FIELDS:
238 # attempt to copy relevant fields from the payload into the LogEntry body
239 if field in info and field not in kw:
240 kw[field] = info[field]
241 self._do_log(client, StructEntry, info, **kw)
242
243 def log_proto(self, message, *, client=None, **kw):
244 """Log a protobuf message
245
246 See
247 https://cloud.google.com/logging/docs/reference/v2/rest/v2/entries/list
248
249 Args:
250 message (google.protobuf.message.Message):
251 The protobuf message to be logged.
252 client (Optional[~logging_v2.client.Client]):
253 The client to use. If not passed, falls back to the
254 ``client`` stored on the current sink.
255 kw (Optional[dict]): additional keyword arguments for the entry.
256 See :class:`~logging_v2.entries.LogEntry`.
257 """
258 self._do_log(client, ProtobufEntry, message, **kw)
259
260 def log(self, message=None, *, client=None, **kw):
261 """Log an arbitrary message. Type will be inferred based on the input.
262
263 See
264 https://cloud.google.com/logging/docs/reference/v2/rest/v2/entries/list
265
266 Args:
267 message (Optional[str or dict or google.protobuf.Message]): The message. to log
268 client (Optional[~logging_v2.client.Client]):
269 The client to use. If not passed, falls back to the
270 ``client`` stored on the current sink.
271 kw (Optional[dict]): additional keyword arguments for the entry.
272 See :class:`~logging_v2.entries.LogEntry`.
273 """
274 if isinstance(message, google.protobuf.message.Message):
275 self.log_proto(message, client=client, **kw)
276 elif isinstance(message, collections.abc.Mapping):
277 self.log_struct(message, client=client, **kw)
278 elif isinstance(message, str):
279 self.log_text(message, client=client, **kw)
280 else:
281 self._do_log(client, LogEntry, message, **kw)
282
283 def delete(self, logger_name=None, *, client=None):
284 """Delete all entries in a logger via a DELETE request
285
286 See
287 https://cloud.google.com/logging/docs/reference/v2/rest/v2/projects.logs/delete
288
289 Args:
290 logger_name (Optional[str]): The resource name of the log to delete:
291
292 ::
293
294 "projects/[PROJECT_ID]/logs/[LOG_ID]"
295 "organizations/[ORGANIZATION_ID]/logs/[LOG_ID]"
296 "billingAccounts/[BILLING_ACCOUNT_ID]/logs/[LOG_ID]"
297 "folders/[FOLDER_ID]/logs/[LOG_ID]"
298
299 ``[LOG_ID]`` must be URL-encoded. For example,
300 ``"projects/my-project-id/logs/syslog"``,
301 ``"organizations/1234567890/logs/cloudresourcemanager.googleapis.com%2Factivity"``.
302 If not passed, defaults to the project bound to the client.
303 client (Optional[~logging_v2.client.Client]):
304 The client to use. If not passed, falls back to the
305 ``client`` stored on the current logger.
306 """
307 client = self._require_client(client)
308 if logger_name is None:
309 logger_name = self.full_name
310 client.logging_api.logger_delete(logger_name)
311
312 def list_entries(
313 self,
314 *,
315 resource_names=None,
316 filter_=None,
317 order_by=None,
318 max_results=None,
319 page_size=None,
320 page_token=None,
321 ):
322 """Return a generator of log entry resources.
323
324 See
325 https://cloud.google.com/logging/docs/reference/v2/rest/v2/entries/list
326
327 Args:
328 resource_names (Optional[Sequence[str]]): Names of one or more parent resources
329 from which to retrieve log entries:
330
331 ::
332
333 "projects/[PROJECT_ID]"
334 "organizations/[ORGANIZATION_ID]"
335 "billingAccounts/[BILLING_ACCOUNT_ID]"
336 "folders/[FOLDER_ID]"
337
338 If not passed, defaults to the project bound to the client.
339 filter_ (Optional[str]): a filter expression. See
340 https://cloud.google.com/logging/docs/view/advanced_filters
341 By default, a 24 hour filter is applied.
342 order_by (Optional[str]): One of :data:`~logging_v2.ASCENDING`
343 or :data:`~logging_v2.DESCENDING`.
344 max_results (Optional[int]):
345 Optional. The maximum number of entries to return.
346 Non-positive values are treated as 0. If None, uses API defaults.
347 page_size (int): number of entries to fetch in each API call. Although
348 requests are paged internally, logs are returned by the generator
349 one at a time. If not passed, defaults to a value set by the API.
350 page_token (str): opaque marker for the starting "page" of entries. If not
351 passed, the API will return the first page of entries.
352 Returns:
353 Generator[~logging_v2.LogEntry]
354 """
355
356 if resource_names is None:
357 resource_names = [f"projects/{self.project}"]
358
359 log_filter = f"logName={self.full_name}"
360 if filter_ is not None:
361 filter_ = f"{filter_} AND {log_filter}"
362 else:
363 filter_ = log_filter
364 filter_ = _add_defaults_to_filter(filter_)
365 return self.client.list_entries(
366 resource_names=resource_names,
367 filter_=filter_,
368 order_by=order_by,
369 max_results=max_results,
370 page_size=page_size,
371 page_token=page_token,
372 )
373
374
375class Batch(object):
376 def __init__(self, logger, client, *, resource=None):
377 """Context manager: collect entries to log via a single API call.
378
379 Helper returned by :meth:`Logger.batch`
380
381 Args:
382 logger (logging_v2.logger.Logger):
383 the logger to which entries will be logged.
384 client (~logging_V2.client.Client):
385 The client to use.
386 resource (Optional[~logging_v2.resource.Resource]):
387 Monitored resource of the batch, defaults
388 to None, which requires that every entry should have a
389 resource specified. Since the methods used to write
390 entries default the entry's resource to the global
391 resource type, this parameter is only required
392 if explicitly set to None. If no entries' resource are
393 set to None, this parameter will be ignored on the server.
394 """
395 self.logger = logger
396 self.entries = []
397 self.client = client
398 self.resource = resource
399
400 def __enter__(self):
401 return self
402
403 def __exit__(self, exc_type, exc_val, exc_tb):
404 if exc_type is None:
405 self.commit()
406
407 def log_empty(self, **kw):
408 """Add a entry without payload to be logged during :meth:`commit`.
409
410 Args:
411 kw (Optional[dict]): Additional keyword arguments for the entry.
412 See :class:`~logging_v2.entries.LogEntry`.
413 """
414 self.entries.append(LogEntry(**kw))
415
416 def log_text(self, text, **kw):
417 """Add a text entry to be logged during :meth:`commit`.
418
419 Args:
420 text (str): the text entry
421 kw (Optional[dict]): Additional keyword arguments for the entry.
422 See :class:`~logging_v2.entries.LogEntry`.
423 """
424 self.entries.append(TextEntry(payload=text, **kw))
425
426 def log_struct(self, info, **kw):
427 """Add a struct entry to be logged during :meth:`commit`.
428
429 The message must be able to be serializable to a Protobuf Struct.
430 It must be a dictionary of strings to one of the following:
431
432 - :class:`str`
433 - :class:`int`
434 - :class:`float`
435 - :class:`bool`
436 - :class:`list[str|float|int|bool|list|dict|None]`
437 - :class:`dict[str, str|float|int|bool|list|dict|None]`
438
439 For more details on Protobuf structs, see https://protobuf.dev/reference/protobuf/google.protobuf/#value.
440 If the provided dictionary cannot be serialized into a Protobuf struct,
441 it will not be logged, and a :class:`ValueError` will be raised during :meth:`commit`.
442
443 Args:
444 info (dict[str, str|float|int|bool|list|dict|None]): The struct entry,
445 kw (Optional[dict]): Additional keyword arguments for the entry.
446 See :class:`~logging_v2.entries.LogEntry`.
447 """
448 self.entries.append(StructEntry(payload=info, **kw))
449
450 def log_proto(self, message, **kw):
451 """Add a protobuf entry to be logged during :meth:`commit`.
452
453 Args:
454 message (google.protobuf.Message): The protobuf entry.
455 kw (Optional[dict]): Additional keyword arguments for the entry.
456 See :class:`~logging_v2.entries.LogEntry`.
457 """
458 self.entries.append(ProtobufEntry(payload=message, **kw))
459
460 def log(self, message=None, **kw):
461 """Add an arbitrary message to be logged during :meth:`commit`.
462 Type will be inferred based on the input message.
463
464 Args:
465 message (Optional[str or dict or google.protobuf.Message]): The message. to log
466 kw (Optional[dict]): Additional keyword arguments for the entry.
467 See :class:`~logging_v2.entries.LogEntry`.
468 """
469 entry_type = LogEntry
470 if isinstance(message, google.protobuf.message.Message):
471 entry_type = ProtobufEntry
472 elif isinstance(message, collections.abc.Mapping):
473 entry_type = StructEntry
474 elif isinstance(message, str):
475 entry_type = TextEntry
476 self.entries.append(entry_type(payload=message, **kw))
477
478 def commit(self, *, client=None, partial_success=True):
479 """Send saved log entries as a single API call.
480
481 Args:
482 client (Optional[~logging_v2.client.Client]):
483 The client to use. If not passed, falls back to the
484 ``client`` stored on the current batch.
485 partial_success (Optional[bool]):
486 Whether a batch's valid entries should be written even
487 if some other entry failed due to a permanent error such
488 as INVALID_ARGUMENT or PERMISSION_DENIED.
489
490 Raises:
491 ValueError:
492 if one of the messages in the batch cannot be successfully parsed.
493 """
494 if client is None:
495 client = self.client
496
497 kwargs = {"logger_name": self.logger.full_name}
498
499 if self.resource is not None:
500 kwargs["resource"] = self.resource._to_dict()
501
502 if self.logger.labels is not None:
503 kwargs["labels"] = self.logger.labels
504
505 entries = [entry.to_api_repr() for entry in self.entries]
506 try:
507 client.logging_api.write_entries(
508 entries, partial_success=partial_success, **kwargs
509 )
510 except InvalidArgument as e:
511 # InvalidArgument is often sent when a log is too large
512 # attempt to attach extra contex on which log caused error
513 self._append_context_to_error(e)
514 raise e
515 del self.entries[:]
516
517 def _append_context_to_error(self, err):
518 """
519 Attempts to Modify `write_entries` exception messages to contain
520 context on which log in the batch caused the error.
521
522 Best-effort basis. If another exception occurs while processing the
523 input exception, the input will be left unmodified
524
525 Args:
526 err (~google.api_core.exceptions.InvalidArgument):
527 The original exception object
528 """
529 try:
530 # find debug info proto if in details
531 debug_info = next(x for x in err.details if isinstance(x, DebugInfo))
532 # parse out the index of the faulty entry
533 error_idx = re.search("(?<=key: )[0-9]+", debug_info.detail).group(0)
534 # find the faulty entry object
535 found_entry = self.entries[int(error_idx)]
536 str_entry = str(found_entry.to_api_repr())
537 # modify error message to contain extra context
538 err.message = f"{err.message}: {str_entry:.2000}..."
539 except Exception:
540 # if parsing fails, abort changes and leave err unmodified
541 pass