Skip to content

Commit c9ae87a

Browse files
authored
Logging exporter support to output structured json instead of making RPCs (#440)
* Logging exporter support to output structured json instead of making RPCs Fixes #383 I updated the snapshot/golden tests to capture both the structured json and RPC variant. * add docs
1 parent 58f22f3 commit c9ae87a

File tree

28 files changed

+507
-58
lines changed

28 files changed

+507
-58
lines changed

opentelemetry-exporter-gcp-logging/README.rst

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,30 @@ Usage
6868
6969
logger1.warning("string log %s", "here")
7070
71+
If your code is running in a GCP environment with a supported Cloud Logging agent (like GKE,
72+
Cloud Run, GCE, etc.), you can write logs to stdout in Cloud Logging `structured JSON format
73+
<https://cloud.google.com/logging/docs/structured-logging>`_. Pass the ``structured_json_file``
74+
argument and use ``SimpleLogRecordProcessor``:
75+
76+
.. code:: python
77+
78+
import sys
79+
from opentelemetry.exporter.cloud_logging import (
80+
CloudLoggingExporter,
81+
)
82+
from opentelemetry._logs import set_logger_provider
83+
from opentelemetry.sdk._logs import LoggerProvider
84+
from opentelemetry.sdk._logs.export import SimpleLogRecordProcessor
85+
86+
logger_provider = LoggerProvider()
87+
set_logger_provider(logger_provider)
88+
exporter = CloudLoggingExporter(structured_json_file=sys.stdout)
89+
logger_provider.add_log_record_processor(SimpleLogRecordProcessor(exporter))
90+
91+
92+
otel_logger = logger_provider.get_logger(__name__)
93+
otel_logger.emit(attributes={"hello": "world"}, body={"foo": {"bar": "baz"}})
94+
7195
References
7296
----------
7397

opentelemetry-exporter-gcp-logging/src/opentelemetry/exporter/cloud_logging/__init__.py

Lines changed: 109 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,16 @@
1919
import logging
2020
import re
2121
from 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

2433
import google.auth
2534
from google.api.monitored_resource_pb2 import ( # pylint: disable = no-name-in-module
@@ -36,6 +45,7 @@
3645
from google.logging.type.log_severity_pb2 import ( # pylint: disable = no-name-in-module
3746
LogSeverity,
3847
)
48+
from google.protobuf.json_format import MessageToDict
3949
from google.protobuf.struct_pb2 import ( # pylint: disable = no-name-in-module
4050
Struct,
4151
)
@@ -52,6 +62,9 @@
5262
from opentelemetry.sdk.resources import Resource
5363
from opentelemetry.trace import format_span_id, format_trace_id
5464
from opentelemetry.util.types import AnyValue
65+
from proto.datetime_helpers import ( # type: ignore[import]
66+
DatetimeWithNanoseconds,
67+
)
5568

5669
DEFAULT_MAX_ENTRY_SIZE = 256000 # 256 KB
5770
DEFAULT_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
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
[
2+
{
3+
"gen_ai.input.messages": [
4+
{
5+
"parts": [
6+
{
7+
"content": "Get weather details in New Delhi and San Francisco?",
8+
"type": "text"
9+
}
10+
],
11+
"role": "user"
12+
},
13+
{
14+
"parts": [
15+
{
16+
"arguments": {
17+
"location": "New Delhi"
18+
},
19+
"id": "get_current_weather_0",
20+
"name": "get_current_weather",
21+
"type": "tool_call"
22+
},
23+
{
24+
"arguments": {
25+
"location": "San Francisco"
26+
},
27+
"id": "get_current_weather_1",
28+
"name": "get_current_weather",
29+
"type": "tool_call"
30+
}
31+
],
32+
"role": "model"
33+
},
34+
{
35+
"parts": [
36+
{
37+
"id": "get_current_weather_0",
38+
"response": {
39+
"content": "{\"temperature\": 35, \"unit\": \"C\"}"
40+
},
41+
"type": "tool_call_response"
42+
},
43+
{
44+
"id": "get_current_weather_1",
45+
"response": {
46+
"content": "{\"temperature\": 25, \"unit\": \"C\"}"
47+
},
48+
"type": "tool_call_response"
49+
}
50+
],
51+
"role": "user"
52+
}
53+
],
54+
"gen_ai.output.messages": [
55+
{
56+
"finish_reason": "stop",
57+
"parts": [
58+
{
59+
"content": "The current temperature in New Delhi is 35°C, and in San Francisco, it is 25°C.",
60+
"type": "text"
61+
}
62+
],
63+
"role": "model"
64+
}
65+
],
66+
"gen_ai.system_instructions": [
67+
{
68+
"content": "You are a clever language model",
69+
"type": "text"
70+
}
71+
],
72+
"logging.googleapis.com/labels": {
73+
"event.name": "gen_ai.client.inference.operation.details"
74+
},
75+
"logging.googleapis.com/spanId": "",
76+
"logging.googleapis.com/trace": "",
77+
"logging.googleapis.com/trace_sampled": false,
78+
"severity": "DEFAULT",
79+
"time": "2025-01-15T21:25:10.997977393Z"
80+
}
81+
]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
[
2+
{
3+
"logging.googleapis.com/labels": {},
4+
"logging.googleapis.com/spanId": "",
5+
"logging.googleapis.com/trace": "",
6+
"logging.googleapis.com/trace_sampled": false,
7+
"message": "MTIz",
8+
"severity": "DEFAULT",
9+
"time": "2025-01-15T21:25:10.997977393Z"
10+
}
11+
]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
[
2+
{
3+
"kvlistValue": {
4+
"bytes_field": "Ynl0ZXM=",
5+
"repeated_bytes_field": [
6+
"Ynl0ZXM=",
7+
"Ynl0ZXM=",
8+
"Ynl0ZXM="
9+
],
10+
"values": [
11+
{
12+
"key": "content",
13+
"value": {
14+
"stringValue": "You're a helpful assistant."
15+
}
16+
}
17+
]
18+
},
19+
"logging.googleapis.com/labels": {
20+
"event.name": "random.genai.event",
21+
"gen_ai.system": "true",
22+
"test": "23"
23+
},
24+
"logging.googleapis.com/spanId": "0000000000000016",
25+
"logging.googleapis.com/trace": "projects/fakeproject/traces/00000000000000000000000000000019",
26+
"logging.googleapis.com/trace_sampled": false,
27+
"severity": "ERROR",
28+
"time": "2025-01-15T21:25:10.997977393Z"
29+
}
30+
]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
[
2+
{
3+
"Classe": [
4+
"Email addresses",
5+
"Passwords"
6+
],
7+
"CreationDate": "2012-05-05",
8+
"Date": "2016-05-21T21:35:40Z",
9+
"Link": "http://some_link.com",
10+
"LogoType": "png",
11+
"Ref": 164611595.0,
12+
"logging.googleapis.com/labels": {
13+
"boolArray": "[true,false,true,true]",
14+
"float": "25.43231",
15+
"int": "25",
16+
"intArray": "[21,18,23,17]"
17+
},
18+
"logging.googleapis.com/spanId": "",
19+
"logging.googleapis.com/trace": "",
20+
"logging.googleapis.com/trace_sampled": false,
21+
"severity": "DEFAULT",
22+
"time": "2025-01-15T21:25:10.997977393Z"
23+
}
24+
]

0 commit comments

Comments
 (0)