Skip to content

Commit b7360f8

Browse files
committed
refactor: convert API LogRecord to SDK LogRecord, add unit test
1 parent 78bc6bf commit b7360f8

File tree

2 files changed

+94
-9
lines changed

2 files changed

+94
-9
lines changed

util/opentelemetry-util-genai/src/opentelemetry/util/genai/generators.py

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,10 @@
3838
from uuid import UUID
3939

4040
from opentelemetry import trace
41-
from opentelemetry._logs import Logger, LogRecord
41+
from opentelemetry._logs import Logger
4242
from opentelemetry.context import Context, get_current
4343
from opentelemetry.metrics import Histogram, Meter, get_meter
44+
from opentelemetry.sdk._logs._internal import LogRecord as SDKLogRecord
4445
from opentelemetry.semconv._incubating.attributes import (
4546
gen_ai_attributes as GenAI,
4647
)
@@ -85,7 +86,14 @@ def _message_to_log_record(
8586
provider_name: Optional[str],
8687
framework: Optional[str],
8788
capture_content: bool,
88-
) -> Optional[LogRecord]:
89+
) -> Optional[SDKLogRecord]:
90+
"""Build an SDK LogRecord for an input message.
91+
92+
Returns an SDK-level LogRecord configured with:
93+
- body: structured payload for the message (when capture_content is True)
94+
- attributes: includes semconv fields and attributes["event.name"]
95+
- event_name: mirrors the event name for SDK consumers
96+
"""
8997
content = _get_property_value(message, "content")
9098
message_type = _get_property_value(message, "type")
9199

@@ -98,15 +106,17 @@ def _message_to_log_record(
98106
"gen_ai.framework": framework,
99107
# TODO: Convert below to constant once opentelemetry.semconv._incubating.attributes.gen_ai_attributes is available
100108
"gen_ai.provider.name": provider_name,
109+
# Prefer structured logs; include event name as an attribute.
110+
"event.name": "gen_ai.client.inference.operation.details",
101111
}
102112

103113
if capture_content:
104114
attributes["gen_ai.input.messages"] = [message._to_semconv_dict()]
105115

106-
return LogRecord(
107-
event_name="gen_ai.client.inference.operation.details",
108-
attributes=attributes,
116+
return SDKLogRecord(
109117
body=body or None,
118+
attributes=attributes,
119+
event_name="gen_ai.client.inference.operation.details",
110120
)
111121

112122

@@ -116,14 +126,21 @@ def _chat_generation_to_log_record(
116126
provider_name: Optional[str],
117127
framework: Optional[str],
118128
capture_content: bool,
119-
) -> Optional[LogRecord]:
129+
) -> Optional[SDKLogRecord]:
130+
"""Build an SDK LogRecord for a chat generation (choice) item.
131+
132+
Sets both the SDK event_name and attributes["event.name"] to "gen_ai.choice",
133+
and includes structured fields in body (index, finish_reason, message).
134+
"""
120135
if not chat_generation:
121136
return None
122137
attributes = {
123138
# TODO: add below to opentelemetry.semconv._incubating.attributes.gen_ai_attributes
124139
"gen_ai.framework": framework,
125140
# TODO: Convert below to constant once opentelemetry.semconv._incubating.attributes.gen_ai_attributes is available
126141
"gen_ai.provider.name": provider_name,
142+
# Prefer structured logs; include event name as an attribute.
143+
"event.name": "gen_ai.choice",
127144
}
128145

129146
message = {
@@ -138,10 +155,10 @@ def _chat_generation_to_log_record(
138155
"message": message,
139156
}
140157

141-
return LogRecord(
142-
event_name="gen_ai.choice",
143-
attributes=attributes,
158+
return SDKLogRecord(
144159
body=body or None,
160+
attributes=attributes,
161+
event_name="gen_ai.choice",
145162
)
146163

147164

@@ -376,6 +393,7 @@ def start(self, invocation: LLMInvocation):
376393
capture_content=self._capture_content,
377394
)
378395
if log and self._logger:
396+
# _message_to_log_record returns an SDKLogRecord
379397
self._logger.emit(log)
380398

381399
def finish(self, invocation: LLMInvocation):

util/opentelemetry-util-genai/tests/test_utils.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,18 @@
33
import pytest
44

55
from opentelemetry import trace
6+
from opentelemetry.sdk._logs import LoggerProvider
7+
from opentelemetry.sdk._logs.export import (
8+
InMemoryLogExporter,
9+
SimpleLogRecordProcessor,
10+
)
611
from opentelemetry.sdk.trace import TracerProvider
712
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
813
from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
914
InMemorySpanExporter,
1015
)
1116
from opentelemetry.util.genai.handler import (
17+
TelemetryHandler,
1218
llm_start,
1319
llm_stop,
1420
)
@@ -79,3 +85,64 @@ def test_llm_start_and_stop_creates_span(
7985
assert invocation.run_id == run_id
8086
assert invocation.attributes.get("custom_attr") == "value"
8187
assert invocation.attributes.get("extra") == "info"
88+
89+
90+
def test_structured_logs_emitted():
91+
# Configure in-memory log exporter and provider
92+
log_exporter = InMemoryLogExporter()
93+
logger_provider = LoggerProvider()
94+
logger_provider.add_log_record_processor(
95+
SimpleLogRecordProcessor(log_exporter)
96+
)
97+
98+
# Build a dedicated TelemetryHandler using our logger provider
99+
handler = TelemetryHandler(
100+
emitter_type_full=True,
101+
logger_provider=logger_provider,
102+
)
103+
104+
run_id = uuid4()
105+
message = Message(content="hello world", type="user", name="msg")
106+
generation = ChatGeneration(
107+
content="hello back",
108+
type="assistant",
109+
finish_reason="stop",
110+
)
111+
112+
# Start and stop via the handler (emits logs at start and finish)
113+
handler.start_llm(
114+
[message], run_id=run_id, system="test-system", framework="pytest"
115+
)
116+
handler.stop_llm(run_id, chat_generations=[generation])
117+
118+
# Collect logs
119+
logs = log_exporter.get_finished_logs()
120+
# Expect one input-detail log and one choice log
121+
assert len(logs) == 2
122+
records = [ld.log_record for ld in logs]
123+
124+
# Assert the first record contains structured details for the input message
125+
# Note: order of records is exporter-specific; sort by event.name for stability
126+
records_by_event = {
127+
rec.attributes.get("event.name"): rec for rec in records
128+
}
129+
130+
input_rec = records_by_event["gen_ai.client.inference.operation.details"]
131+
assert input_rec.attributes.get("gen_ai.provider.name") == "test-system"
132+
assert input_rec.attributes.get("gen_ai.framework") == "pytest"
133+
assert input_rec.body == {
134+
"type": "user",
135+
"content": "hello world",
136+
}
137+
138+
choice_rec = records_by_event["gen_ai.choice"]
139+
assert choice_rec.attributes.get("gen_ai.provider.name") == "test-system"
140+
assert choice_rec.attributes.get("gen_ai.framework") == "pytest"
141+
assert choice_rec.body == {
142+
"index": 0,
143+
"finish_reason": "stop",
144+
"message": {
145+
"type": "assistant",
146+
"content": "hello back",
147+
},
148+
}

0 commit comments

Comments
 (0)