Skip to content
Merged
4 changes: 2 additions & 2 deletions dev-constraints.txt
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,5 @@ setuptools==69.5.1

# pinned for snapshot tests. this should be bumped regularly and snapshots updated by running
# tox -f py311-test -- --snapshot-update
opentelemetry-api==1.30.0
opentelemetry-sdk==1.30.0
opentelemetry-api==1.35.0
opentelemetry-sdk==1.35.0
3 changes: 3 additions & 0 deletions opentelemetry-exporter-gcp-logging/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
- Do not call `logging.warning` when `LogRecord.body` is of None type, instead leave `LogEntry.payload` empty.
- Update opentelemetry-api/sdk dependencies to 1.3.

The suffix part of `LogEntry.log_name` will be the `LogRecord.event_name` when
that is present and the `gcp.log_name` attribute is not.

## Version 1.9.0a0

Released 2025-02-03
4 changes: 2 additions & 2 deletions opentelemetry-exporter-gcp-logging/setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ package_dir=
packages=find_namespace:
install_requires =
google-cloud-logging ~= 3.0
opentelemetry-sdk ~= 1.30
opentelemetry-api ~= 1.30
opentelemetry-sdk >= 1.35.0
opentelemetry-api >= 1.35.0
Comment thread
aabmass marked this conversation as resolved.
opentelemetry-resourcedetector-gcp >= 1.5.0dev0, == 1.*

[options.packages.find]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import datetime
import json
import logging
import re
import urllib.parse
from typing import Any, Mapping, MutableMapping, Optional, Sequence

Expand Down Expand Up @@ -102,6 +103,8 @@
24: LogSeverity.EMERGENCY,
}

INVALID_LOG_NAME_MESSAGE = "%s is not a valid log name. log name must be <512 characters and only contain characters: A-Za-z0-9/-_."


def _convert_any_value_to_string(value: Any) -> str:
if isinstance(value, bool):
Expand Down Expand Up @@ -176,6 +179,12 @@ def _set_payload_in_log_entry(log_entry: LogEntry, body: AnyValue):
log_entry.text_payload = _convert_any_value_to_string(body)


def is_log_id_valid(log_id: str) -> bool:
return len(log_id) < 512 and not bool(
re.search(r"[^A-Za-z0-9\-_/\.]", log_id)
)


class CloudLoggingExporter(LogExporter):
def __init__(
self,
Expand All @@ -201,6 +210,15 @@ def __init__(
)
)

def pick_log_id(self, log_name_attr: Any, event_name: str | None) -> str:
if log_name_attr and isinstance(log_name_attr, str):
if is_log_id_valid(log_name_attr):
return log_name_attr.replace("/", "%2F")
logging.warning(INVALID_LOG_NAME_MESSAGE, log_name_attr)
if event_name and is_log_id_valid(event_name):
return event_name.replace("/", "%2F")
return self.default_log_name

def export(self, batch: Sequence[LogData]):
now = datetime.datetime.now()
log_entries = []
Expand All @@ -211,14 +229,7 @@ def export(self, batch: Sequence[LogData]):
project_id = str(
attributes.get(PROJECT_ID_ATTRIBUTE_KEY, self.project_id)
)
log_suffix = urllib.parse.quote_plus(
str(
attributes.get(
LOG_NAME_ATTRIBUTE_KEY, self.default_log_name
)
)
)
log_entry.log_name = f"projects/{project_id}/logs/{log_suffix}"
log_entry.log_name = f"projects/{project_id}/logs/{self.pick_log_id(attributes.get(LOG_NAME_ATTRIBUTE_KEY), log_record.event_name)}"
# If timestamp is unset fall back to observed_time_unix_nano as recommended,
# see https://github.com/open-telemetry/opentelemetry-proto/blob/4abbb78/opentelemetry/proto/logs/v1/logs.proto#L176-L179
ts = Timestamp()
Expand Down Expand Up @@ -256,6 +267,8 @@ def export(self, batch: Sequence[LogData]):
k: _convert_any_value_to_string(v)
for k, v in attributes.items()
}
if log_record.event_name:
log_entry.labels["event.name"] = log_record.event_name
_set_payload_in_log_entry(log_entry, log_record.body)
log_entries.append(log_entry)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
[
{
"entries": [
{
"jsonPayload": {
"gen_ai.input.messages": [
{
"parts": [
{
"content": "Get weather details in New Delhi and San Francisco?",
"type": "text"
}
],
"role": "user"
},
{
"parts": [
{
"arguments": {
"location": "New Delhi"
},
"id": "get_current_weather_0",
"name": "get_current_weather",
"type": "tool_call"
},
{
"arguments": {
"location": "San Francisco"
},
"id": "get_current_weather_1",
"name": "get_current_weather",
"type": "tool_call"
}
],
"role": "model"
},
{
"parts": [
{
"id": "get_current_weather_0",
"response": {
"content": "{\"temperature\": 35, \"unit\": \"C\"}"
},
"type": "tool_call_response"
},
{
"id": "get_current_weather_1",
"response": {
"content": "{\"temperature\": 25, \"unit\": \"C\"}"
},
"type": "tool_call_response"
}
],
"role": "user"
}
],
"gen_ai.output.messages": [
{
"finish_reason": "stop",
"parts": [
{
"content": "The current temperature in New Delhi is 35°C, and in San Francisco, it is 25°C.",
"type": "text"
}
],
"role": "model"
}
],
"gen_ai.system_instructions": [
{
"content": "You are a clever language model",
"type": "text"
}
]
},
"labels": {
"event.name": "gen_ai.client.inference.operation.details"
},
"logName": "projects/fakeproject/logs/gen_ai.client.inference.operation.details",
"resource": {
"labels": {
"location": "global",
"namespace": "",
"node_id": ""
},
"type": "generic_node"
},
"timestamp": "2025-01-15T21:25:10.997977393Z"
}
],
"partialSuccess": true
}
]
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@
}
},
"labels": {
"event.name": "gen_ai.system.message",
"event.name": "random.genai.event",
"gen_ai.system": "true",
"test": "23"
},
"logName": "projects/fakeproject/logs/test",
"logName": "projects/fakeproject/logs/random.genai.event",
"resource": {
"labels": {
"location": "global",
Expand Down
121 changes: 119 additions & 2 deletions opentelemetry-exporter-gcp-logging/tests/test_cloud_logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,10 @@
LoggingServiceV2Client,
)
from opentelemetry._logs.severity import SeverityNumber
from opentelemetry.exporter.cloud_logging import CloudLoggingExporter
from opentelemetry.exporter.cloud_logging import (
CloudLoggingExporter,
is_log_id_valid,
)
from opentelemetry.sdk._logs import LogData
from opentelemetry.sdk._logs._internal import LogRecord
from opentelemetry.sdk.resources import Resource
Expand Down Expand Up @@ -74,14 +77,14 @@ def test_convert_otlp_dict_body(
log_data = [
LogData(
log_record=LogRecord(
event_name="random.genai.event",
timestamp=1736976310997977393,
severity_number=SeverityNumber(20),
trace_id=25,
span_id=22,
attributes={
"gen_ai.system": True,
"test": 23,
"event.name": "gen_ai.system.message",
},
body={
"kvlistValue": {
Expand Down Expand Up @@ -153,6 +156,120 @@ def test_convert_non_json_dict_bytes(
assert cloudloggingfake.get_calls() == snapshot_writelogentrycalls


def test_convert_gen_ai_body(
cloudloggingfake: CloudLoggingFake,
snapshot_writelogentrycalls: List[WriteLogEntriesCall],
) -> None:
log_data = [
LogData(
log_record=LogRecord(
event_name="gen_ai.client.inference.operation.details",
timestamp=1736976310997977393,
body={
"gen_ai.input.messages": (
{
"role": "user",
"parts": (
{
"type": "text",
"content": "Get weather details in New Delhi and San Francisco?",
},
),
},
{
"role": "model",
"parts": (
{
"type": "tool_call",
"arguments": {"location": "New Delhi"},
"name": "get_current_weather",
"id": "get_current_weather_0",
},
{
"type": "tool_call",
"arguments": {"location": "San Francisco"},
"name": "get_current_weather",
"id": "get_current_weather_1",
},
),
},
{
"role": "user",
"parts": (
{
"type": "tool_call_response",
"response": {
"content": '{"temperature": 35, "unit": "C"}'
},
"id": "get_current_weather_0",
},
{
"type": "tool_call_response",
"response": {
"content": '{"temperature": 25, "unit": "C"}'
},
"id": "get_current_weather_1",
},
),
},
),
"gen_ai.system_instructions": (
{
"type": "text",
"content": "You are a clever language model",
},
),
"gen_ai.output.messages": (
{
"role": "model",
"parts": (
{
"type": "text",
"content": "The current temperature in New Delhi is 35°C, and in San Francisco, it is 25°C.",
},
),
"finish_reason": "stop",
},
),
},
),
instrumentation_scope=InstrumentationScope("test"),
)
]
cloudloggingfake.exporter.export(log_data)
assert cloudloggingfake.get_calls() == snapshot_writelogentrycalls


def test_is_log_id_valid():
assert is_log_id_valid(";") is False
assert is_log_id_valid("aB12//..--__") is True
assert is_log_id_valid("a" * 512) is False
assert is_log_id_valid("abc1212**") is False
assert is_log_id_valid("gen_ai.client.inference.operation.details") is True


def test_pick_log_id() -> None:
exporter = CloudLoggingExporter(
client=LoggingServiceV2Client(credentials=AnonymousCredentials()),
project_id=PROJECT_ID,
default_log_name="test",
)
assert (
exporter.pick_log_id("valid_log_name_attr", "event_name_str")
== "valid_log_name_attr"
)
assert (
exporter.pick_log_id("invalid_attr**2", "event_name_str")
== "event_name_str"
)
assert exporter.pick_log_id(None, "event_name_str") == "event_name_str"
assert exporter.pick_log_id(None, None) == exporter.default_log_name
assert (
exporter.pick_log_id(None, "invalid_event_name_id24$")
== exporter.default_log_name
)


@pytest.mark.parametrize(
"body",
[
Expand Down