1919import logging
2020import re
2121from base64 import b64encode
22- from typing import Any , Mapping , MutableMapping , Optional , Sequence
22+ from functools import partial
23+ from typing import (
24+ Any ,
25+ Mapping ,
26+ MutableMapping ,
27+ Optional ,
28+ Sequence ,
29+ TextIO ,
30+ cast ,
31+ )
2332
2433import google .auth
2534from google .api .monitored_resource_pb2 import ( # pylint: disable = no-name-in-module
3645from google .logging .type .log_severity_pb2 import ( # pylint: disable = no-name-in-module
3746 LogSeverity ,
3847)
48+ from google .protobuf .json_format import MessageToDict
3949from google .protobuf .struct_pb2 import ( # pylint: disable = no-name-in-module
4050 Struct ,
4151)
5262from opentelemetry .sdk .resources import Resource
5363from opentelemetry .trace import format_span_id , format_trace_id
5464from opentelemetry .util .types import AnyValue
65+ from proto .datetime_helpers import ( # type: ignore[import]
66+ DatetimeWithNanoseconds ,
67+ )
5568
5669DEFAULT_MAX_ENTRY_SIZE = 256000 # 256 KB
5770DEFAULT_MAX_REQUEST_SIZE = 10000000 # 10 MB
@@ -205,24 +218,59 @@ def __init__(
205218 project_id : Optional [str ] = None ,
206219 default_log_name : Optional [str ] = None ,
207220 client : Optional [LoggingServiceV2Client ] = None ,
208- ):
221+ * ,
222+ structured_json_file : Optional [TextIO ] = None ,
223+ ) -> None :
224+ """Create a CloudLoggingExporter
225+
226+ Args:
227+ project_id: The GCP project ID to which the logs will be sent. If not
228+ provided, the exporter will infer it from Application Default Credentials.
229+ default_log_name: The default log name to use for log entries.
230+ If not provided, a default name will be used.
231+ client: An optional `LoggingServiceV2Client` instance to use for
232+ sending logs. If not provided and ``structured_json_file`` is not provided, a
233+ new client will be created. Passing both ``client`` and
234+ ``structured_json_file`` is not supported.
235+ structured_json_file: An optional file-like object (like `sys.stdout`) to write
236+ logs to in Cloud Logging `structured JSON format
237+ <https://cloud.google.com/logging/docs/structured-logging>`_. If provided,
238+ ``client`` must not be provided and logs will only be written to the file-like
239+ object.
240+ """
241+
209242 self .project_id : str
210243 if not project_id :
211244 _ , default_project_id = google .auth .default ()
212245 self .project_id = str (default_project_id )
213246 else :
214247 self .project_id = project_id
248+
215249 if default_log_name :
216250 self .default_log_name = default_log_name
217251 else :
218252 self .default_log_name = "otel_python_inprocess_log_name_temp"
219- self .client = client or LoggingServiceV2Client (
220- transport = LoggingServiceV2GrpcTransport (
221- channel = LoggingServiceV2GrpcTransport .create_channel (
222- options = _OPTIONS ,
253+
254+ if client and structured_json_file :
255+ raise ValueError (
256+ "Cannot specify both client and structured_json_file"
257+ )
258+
259+ if structured_json_file :
260+ self ._write_log_entries = partial (
261+ self ._write_log_entries_to_file , structured_json_file
262+ )
263+ else :
264+ client = client or LoggingServiceV2Client (
265+ transport = LoggingServiceV2GrpcTransport (
266+ channel = LoggingServiceV2GrpcTransport .create_channel (
267+ options = _OPTIONS ,
268+ )
223269 )
224270 )
225- )
271+ self ._write_log_entries = partial (
272+ self ._write_log_entries_to_client , client
273+ )
226274
227275 def pick_log_id (self , log_name_attr : Any , event_name : str | None ) -> str :
228276 if log_name_attr and isinstance (log_name_attr , str ):
@@ -288,7 +336,58 @@ def export(self, batch: Sequence[LogData]):
288336
289337 self ._write_log_entries (log_entries )
290338
291- def _write_log_entries (self , log_entries : list [LogEntry ]):
339+ @staticmethod
340+ def _write_log_entries_to_file (file : TextIO , log_entries : list [LogEntry ]):
341+ """Formats logs into the Cloud Logging structured log format, and writes them to the
342+ specified file-like object
343+
344+ See https://cloud.google.com/logging/docs/structured-logging
345+ """
346+ # TODO: this is not resilient to exceptions which can cause recursion when using OTel's
347+ # logging handler. See
348+ # https://github.com/open-telemetry/opentelemetry-python/issues/4261 for outstanding
349+ # issue in OTel.
350+
351+ for entry in log_entries :
352+ json_dict : dict [str , Any ] = {}
353+
354+ # These are not added in export() so not added to the JSON here.
355+ # - httpRequest
356+ # - logging.googleapis.com/sourceLocation
357+ # - logging.googleapis.com/operation
358+ # - logging.googleapis.com/insertId
359+
360+ # https://cloud.google.com/logging/docs/agent/logging/configuration#timestamp-processing
361+ timestamp = cast (DatetimeWithNanoseconds , entry .timestamp )
362+ json_dict ["time" ] = timestamp .rfc3339 ()
363+
364+ json_dict ["severity" ] = LogSeverity .Name (
365+ cast (LogSeverity .ValueType , entry .severity )
366+ )
367+ json_dict ["logging.googleapis.com/labels" ] = dict (entry .labels )
368+ json_dict ["logging.googleapis.com/spanId" ] = entry .span_id
369+ json_dict [
370+ "logging.googleapis.com/trace_sampled"
371+ ] = entry .trace_sampled
372+ json_dict ["logging.googleapis.com/trace" ] = entry .trace
373+
374+ if entry .text_payload :
375+ json_dict ["message" ] = entry .text_payload
376+ if entry .json_payload :
377+ json_dict .update (
378+ MessageToDict (LogEntry .pb (entry ).json_payload )
379+ )
380+
381+ # Use dumps to avoid invalid json written to the stream if serialization fails for any reason
382+ file .write (
383+ json .dumps (json_dict , separators = ("," , ":" ), sort_keys = True )
384+ + "\n "
385+ )
386+
387+ @staticmethod
388+ def _write_log_entries_to_client (
389+ client : LoggingServiceV2Client , log_entries : list [LogEntry ]
390+ ):
292391 batch : list [LogEntry ] = []
293392 batch_byte_size = 0
294393 for entry in log_entries :
@@ -302,7 +401,7 @@ def _write_log_entries(self, log_entries: list[LogEntry]):
302401 continue
303402 if msg_size + batch_byte_size > DEFAULT_MAX_REQUEST_SIZE :
304403 try :
305- self . client .write_log_entries (
404+ client .write_log_entries (
306405 WriteLogEntriesRequest (
307406 entries = batch , partial_success = True
308407 )
@@ -319,7 +418,7 @@ def _write_log_entries(self, log_entries: list[LogEntry]):
319418 batch_byte_size += msg_size
320419 if batch :
321420 try :
322- self . client .write_log_entries (
421+ client .write_log_entries (
323422 WriteLogEntriesRequest (entries = batch , partial_success = True )
324423 )
325424 # pylint: disable=broad-except
0 commit comments