Skip to content

Commit 08a01de

Browse files
committed
Support conversion of non-string
LogRecord.attributes values and non-dictionary LogRecord.body.
1 parent 2363dd1 commit 08a01de

File tree

7 files changed

+216
-32
lines changed

7 files changed

+216
-32
lines changed

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

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,10 @@
1414
from __future__ import annotations
1515

1616
import datetime
17+
import json
1718
import logging
1819
import urllib.parse
19-
from typing import Optional, Sequence
20+
from typing import Any, Optional, Sequence
2021

2122
import google.auth
2223
from google.api.monitored_resource_pb2 import MonitoredResource # type: ignore
@@ -91,6 +92,16 @@
9192
}
9293

9394

95+
def convert_any_value_to_string(value: Any) -> str:
96+
t = type(value)
97+
if t is bool or t is int or t is float or t is str:
98+
return str(value)
99+
if t is list or t is tuple:
100+
return json.dumps(value)
101+
logging.warning(f"Unknown type {t} found, cannot convert to string.")
102+
return ""
103+
104+
94105
class CloudLoggingExporter(LogExporter):
95106
def __init__(
96107
self,
@@ -171,11 +182,29 @@ def export(self, batch: Sequence[LogData]):
171182
log_entry.severity = SEVERITY_MAPPING[ # type: ignore[assignment]
172183
log_record.severity_number.value # type: ignore[index]
173184
]
174-
log_entry.labels = {k: str(v) for k, v in attributes.items()}
185+
log_entry.labels = {
186+
k: convert_any_value_to_string(v)
187+
for k, v in attributes.items()
188+
}
175189
if type(log_record.body) is dict:
176190
s = Struct()
177191
s.update(log_record.body)
178192
log_entry.json_payload = s
193+
elif type(log_record.body) is bytes:
194+
json_str = log_record.body.decode("utf8")
195+
json_dict = json.loads(json_str)
196+
if type(json_dict) is dict:
197+
s = Struct()
198+
s.update(json_dict)
199+
log_entry.json_payload = s
200+
else:
201+
logging.warning(
202+
f"LogRecord.body was bytes type and json.loads turned body into type {type(json_dict)}, expected a dictionary."
203+
)
204+
else:
205+
log_entry.text_payload = convert_any_value_to_string(
206+
log_record.body
207+
)
179208
log_entries.append(log_entry)
180209

181210
self._write_log_entries(log_entries)
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
[
2+
{
3+
"entries": [
4+
{
5+
"logName": "projects/fakeproject/logs/test",
6+
"resource": {
7+
"labels": {
8+
"location": "global",
9+
"namespace": "",
10+
"node_id": ""
11+
},
12+
"type": "generic_node"
13+
},
14+
"timestamp": "2025-01-15T21:25:10.997977393Z"
15+
}
16+
]
17+
}
18+
]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
[
2+
{
3+
"entries": [
4+
{
5+
"jsonPayload": {
6+
"Classe": [
7+
"Email addresses",
8+
"Passwords"
9+
],
10+
"CreationDate": "2012-05-05",
11+
"Date": "2016-05-21T21:35:40Z",
12+
"Link": "http://some_link.com",
13+
"LogoType": "png",
14+
"Ref": 164611595.0
15+
},
16+
"labels": {
17+
"boolArray": "[true, false, true, true]",
18+
"float": "25.43231",
19+
"int": "25",
20+
"intArray": "[21, 18, 23, 17]"
21+
},
22+
"logName": "projects/fakeproject/logs/test",
23+
"resource": {
24+
"labels": {
25+
"location": "global",
26+
"namespace": "",
27+
"node_id": ""
28+
},
29+
"type": "generic_node"
30+
},
31+
"timestamp": "2025-01-15T21:25:10.997977393Z"
32+
}
33+
]
34+
}
35+
]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
[
2+
{
3+
"entries": [
4+
{
5+
"logName": "projects/fakeproject/logs/test",
6+
"resource": {
7+
"labels": {
8+
"location": "global",
9+
"namespace": "",
10+
"node_id": ""
11+
},
12+
"type": "generic_node"
13+
},
14+
"textPayload": "True",
15+
"timestamp": "2025-01-15T21:25:10.997977393Z"
16+
}
17+
]
18+
}
19+
]
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
[
2+
{
3+
"entries": [
4+
{
5+
"logName": "projects/fakeproject/logs/test",
6+
"resource": {
7+
"labels": {
8+
"location": "global",
9+
"namespace": "",
10+
"node_id": ""
11+
},
12+
"type": "generic_node"
13+
},
14+
"textPayload": "A text body",
15+
"timestamp": "2025-01-15T21:25:10.997977393Z"
16+
}
17+
]
18+
}
19+
]

opentelemetry-exporter-gcp-logging/tests/test_cloud_logging.py

Lines changed: 94 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,9 @@
2626
Be sure to review the changes.
2727
"""
2828
import re
29-
from typing import List
29+
from typing import List, Union
3030

31+
import pytest
3132
from fixtures.cloud_logging_fake import CloudLoggingFake, WriteLogEntriesCall
3233
from google.auth.credentials import AnonymousCredentials
3334
from google.cloud.logging_v2.services.logging_service_v2 import (
@@ -43,7 +44,7 @@
4344
PROJECT_ID = "fakeproject"
4445

4546

46-
def test_invalid_otlp_entries_raise_warnings(caplog) -> None:
47+
def test_too_large_log_raises_warning(caplog) -> None:
4748
client = LoggingServiceV2Client(credentials=AnonymousCredentials())
4849
no_default_logname = CloudLoggingExporter(
4950
project_id=PROJECT_ID, client=client
@@ -52,6 +53,7 @@ def test_invalid_otlp_entries_raise_warnings(caplog) -> None:
5253
[
5354
LogData(
5455
log_record=LogRecord(
56+
body="abc",
5557
resource=Resource({}),
5658
attributes={str(i): "i" * 10000 for i in range(1000)},
5759
),
@@ -63,39 +65,34 @@ def test_invalid_otlp_entries_raise_warnings(caplog) -> None:
6365
assert "exceeds Cloud Logging's maximum limit of 256000.\n" in caplog.text
6466

6567

66-
def test_convert_otlp(
68+
def test_convert_otlp_dict_body(
6769
cloudloggingfake: CloudLoggingFake,
6870
snapshot_writelogentrycalls: List[WriteLogEntriesCall],
6971
) -> None:
70-
# Create a new LogRecord object
71-
log_record = LogRecord(
72-
timestamp=1736976310997977393,
73-
severity_number=SeverityNumber(20),
74-
trace_id=25,
75-
span_id=22,
76-
attributes={
77-
"gen_ai.system": "openai",
78-
"event.name": "gen_ai.system.message",
79-
},
80-
body={
81-
"kvlistValue": {
82-
"values": [
83-
{
84-
"key": "content",
85-
"value": {
86-
"stringValue": "You're a helpful assistant."
87-
},
88-
}
89-
]
90-
}
91-
},
92-
# Not sure why I'm getting AttributeError: 'NoneType' object has no attribute 'attributes' when unset.
93-
resource=Resource({}),
94-
)
95-
9672
log_data = [
9773
LogData(
98-
log_record=log_record,
74+
log_record=LogRecord(
75+
timestamp=1736976310997977393,
76+
severity_number=SeverityNumber(20),
77+
trace_id=25,
78+
span_id=22,
79+
attributes={
80+
"gen_ai.system": "openai",
81+
"event.name": "gen_ai.system.message",
82+
},
83+
body={
84+
"kvlistValue": {
85+
"values": [
86+
{
87+
"key": "content",
88+
"value": {
89+
"stringValue": "You're a helpful assistant."
90+
},
91+
}
92+
]
93+
}
94+
},
95+
),
9996
instrumentation_scope=InstrumentationScope("test"),
10097
)
10198
]
@@ -109,3 +106,70 @@ def test_convert_otlp(
109106
)
110107
is not None
111108
)
109+
110+
111+
def test_convert_otlp_various_different_types_in_attrs_and_bytes_body(
112+
cloudloggingfake: CloudLoggingFake,
113+
snapshot_writelogentrycalls: List[WriteLogEntriesCall],
114+
) -> None:
115+
log_data = [
116+
LogData(
117+
log_record=LogRecord(
118+
timestamp=1736976310997977393,
119+
attributes={
120+
"int": 25,
121+
"float": 25.43231,
122+
"intArray": [21, 18, 23, 17],
123+
"boolArray": [True, False, True, True],
124+
},
125+
body=b'{"Date": "2016-05-21T21:35:40Z", "CreationDate": "2012-05-05", "LogoType": "png", "Ref": 164611595, "Classe": ["Email addresses", "Passwords"],"Link":"http://some_link.com"}',
126+
),
127+
instrumentation_scope=InstrumentationScope("test"),
128+
)
129+
]
130+
cloudloggingfake.exporter.export(log_data)
131+
assert cloudloggingfake.get_calls() == snapshot_writelogentrycalls
132+
133+
134+
def test_convert_non_json_dict_bytes(
135+
cloudloggingfake: CloudLoggingFake,
136+
snapshot_writelogentrycalls: List[WriteLogEntriesCall],
137+
caplog,
138+
) -> None:
139+
log_data = [
140+
LogData(
141+
log_record=LogRecord(
142+
timestamp=1736976310997977393,
143+
body=b"123",
144+
),
145+
instrumentation_scope=InstrumentationScope("test"),
146+
)
147+
]
148+
cloudloggingfake.exporter.export(log_data)
149+
assert cloudloggingfake.get_calls() == snapshot_writelogentrycalls
150+
assert (
151+
"LogRecord.body was bytes type and json.loads turned body into type <class 'int'>, expected a dictionary"
152+
in caplog.text
153+
)
154+
155+
156+
@pytest.mark.parametrize(
157+
"body",
158+
[pytest.param("A text body", id="str"), pytest.param(True, id="bool")],
159+
)
160+
def test_convert_various_types_of_bodies(
161+
cloudloggingfake: CloudLoggingFake,
162+
snapshot_writelogentrycalls: List[WriteLogEntriesCall],
163+
body: Union[str, bool],
164+
) -> None:
165+
log_data = [
166+
LogData(
167+
log_record=LogRecord(
168+
timestamp=1736976310997977393,
169+
body=body,
170+
),
171+
instrumentation_scope=InstrumentationScope("test"),
172+
)
173+
]
174+
cloudloggingfake.exporter.export(log_data)
175+
assert cloudloggingfake.get_calls() == snapshot_writelogentrycalls

0 commit comments

Comments
 (0)