Skip to content

Commit d64969b

Browse files
Update auto instrumentation logic for Semantic Kernel (#136)
* add sk attribute enrichment * organize code and fix test * add tests * address copilot review * address the PR comment --------- Co-authored-by: Nikhil Chitlur Navakiran (from Dev Box) <nikhilc@microsoft.com>
1 parent d207ad3 commit d64969b

File tree

12 files changed

+730
-22
lines changed

12 files changed

+730
-22
lines changed

libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@
1212
)
1313
from .execute_tool_scope import ExecuteToolScope
1414
from .execution_type import ExecutionType
15+
from .exporters.enriched_span import EnrichedReadableSpan
16+
from .exporters.enriching_span_processor import (
17+
get_span_enricher,
18+
register_span_enricher,
19+
unregister_span_enricher,
20+
)
1521
from .inference_call_details import InferenceCallDetails
1622
from .inference_operation_type import InferenceOperationType
1723
from .inference_scope import InferenceScope
@@ -32,6 +38,11 @@
3238
"is_configured",
3339
"get_tracer",
3440
"get_tracer_provider",
41+
# Span enrichment
42+
"register_span_enricher",
43+
"unregister_span_enricher",
44+
"get_span_enricher",
45+
"EnrichedReadableSpan",
3546
# Span processor
3647
"SpanProcessor",
3748
# Base scope class

libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/config.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,13 @@
99
from opentelemetry import trace
1010
from opentelemetry.sdk.resources import SERVICE_NAME, SERVICE_NAMESPACE, Resource
1111
from opentelemetry.sdk.trace import TracerProvider
12-
from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter
12+
from opentelemetry.sdk.trace.export import ConsoleSpanExporter
1313

1414
from .exporters.agent365_exporter import _Agent365Exporter
1515
from .exporters.agent365_exporter_options import Agent365ExporterOptions
16+
from .exporters.enriching_span_processor import (
17+
_EnrichingBatchSpanProcessor,
18+
)
1619
from .exporters.utils import is_agent365_exporter_enabled
1720
from .trace_processor.span_processor import SpanProcessor
1821

@@ -166,8 +169,9 @@ def _configure_internal(
166169

167170
# Add span processors
168171

169-
# Create BatchSpanProcessor with optimized settings
170-
batch_processor = BatchSpanProcessor(exporter, **batch_processor_kwargs)
172+
# Create _EnrichingBatchSpanProcessor with optimized settings
173+
# This allows extensions to enrich spans before export
174+
batch_processor = _EnrichingBatchSpanProcessor(exporter, **batch_processor_kwargs)
171175
agent_processor = SpanProcessor()
172176

173177
tracer_provider.add_span_processor(batch_processor)
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
"""Enriched ReadableSpan wrapper for adding attributes to immutable spans."""
5+
6+
import json
7+
from typing import Any
8+
9+
from opentelemetry.sdk.trace import ReadableSpan
10+
from opentelemetry.util import types
11+
12+
13+
class EnrichedReadableSpan(ReadableSpan):
14+
"""
15+
Wrapper to add attributes to an immutable ReadableSpan.
16+
17+
Since ReadableSpan is immutable after a span ends, this wrapper allows
18+
extensions to add additional attributes before export without modifying
19+
the original span.
20+
"""
21+
22+
def __init__(self, span: ReadableSpan, extra_attributes: dict):
23+
"""
24+
Initialize the enriched span wrapper.
25+
26+
Args:
27+
span: The original ReadableSpan to wrap.
28+
extra_attributes: Additional attributes to merge with the original.
29+
"""
30+
self._span = span
31+
self._extra_attributes = extra_attributes
32+
33+
@property
34+
def attributes(self) -> types.Attributes:
35+
"""Return merged attributes from original span and extra attributes."""
36+
original = dict(self._span.attributes or {})
37+
original.update(self._extra_attributes)
38+
return original
39+
40+
@property
41+
def name(self):
42+
"""Return the span name."""
43+
return self._span.name
44+
45+
@property
46+
def context(self):
47+
"""Return the span context."""
48+
return self._span.context
49+
50+
@property
51+
def parent(self):
52+
"""Return the parent span context."""
53+
return self._span.parent
54+
55+
@property
56+
def start_time(self):
57+
"""Return the span start time."""
58+
return self._span.start_time
59+
60+
@property
61+
def end_time(self):
62+
"""Return the span end time."""
63+
return self._span.end_time
64+
65+
@property
66+
def status(self):
67+
"""Return the span status."""
68+
return self._span.status
69+
70+
@property
71+
def kind(self):
72+
"""Return the span kind."""
73+
return self._span.kind
74+
75+
@property
76+
def events(self):
77+
"""Return the span events."""
78+
return self._span.events
79+
80+
@property
81+
def links(self):
82+
"""Return the span links."""
83+
return self._span.links
84+
85+
@property
86+
def resource(self):
87+
"""Return the span resource."""
88+
return self._span.resource
89+
90+
@property
91+
def instrumentation_scope(self):
92+
"""Return the instrumentation scope."""
93+
return self._span.instrumentation_scope
94+
95+
def to_json(self, indent: int | None = 4) -> str:
96+
"""
97+
Convert span to JSON string with enriched attributes.
98+
99+
Args:
100+
indent: JSON indentation level.
101+
102+
Returns:
103+
JSON string representation of the span.
104+
"""
105+
# Build the JSON dict manually to include enriched attributes
106+
return json.dumps(
107+
{
108+
"name": self.name,
109+
"context": {
110+
"trace_id": f"0x{self.context.trace_id:032x}",
111+
"span_id": f"0x{self.context.span_id:016x}",
112+
"trace_state": str(self.context.trace_state),
113+
}
114+
if self.context
115+
else None,
116+
"kind": str(self.kind),
117+
"parent_id": f"0x{self.parent.span_id:016x}" if self.parent else None,
118+
"start_time": self._format_time(self.start_time),
119+
"end_time": self._format_time(self.end_time),
120+
"status": {
121+
"status_code": str(self.status.status_code),
122+
"description": self.status.description,
123+
}
124+
if self.status
125+
else None,
126+
"attributes": dict(self.attributes) if self.attributes else None,
127+
"events": [self._format_event(e) for e in self.events] if self.events else None,
128+
"links": [self._format_link(lnk) for lnk in self.links] if self.links else None,
129+
"resource": dict(self.resource.attributes) if self.resource else None,
130+
},
131+
indent=indent,
132+
)
133+
134+
def _format_time(self, time_ns: int | None) -> str | None:
135+
"""Format nanosecond timestamp to ISO string."""
136+
if time_ns is None:
137+
return None
138+
from datetime import datetime, timezone
139+
140+
return datetime.fromtimestamp(time_ns / 1e9, tz=timezone.utc).isoformat()
141+
142+
def _format_event(self, event: Any) -> dict:
143+
"""Format a span event."""
144+
return {
145+
"name": event.name,
146+
"timestamp": self._format_time(event.timestamp),
147+
"attributes": dict(event.attributes) if event.attributes else None,
148+
}
149+
150+
def _format_link(self, link: Any) -> dict:
151+
"""Format a span link."""
152+
return {
153+
"context": {
154+
"trace_id": f"0x{link.context.trace_id:032x}",
155+
"span_id": f"0x{link.context.span_id:016x}",
156+
}
157+
if link.context
158+
else None,
159+
"attributes": dict(link.attributes) if link.attributes else None,
160+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
"""Span enrichment support for the Agent365 exporter pipeline."""
5+
6+
import logging
7+
import threading
8+
from collections.abc import Callable
9+
10+
from opentelemetry.sdk.trace import ReadableSpan
11+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
12+
13+
logger = logging.getLogger(__name__)
14+
15+
# Single span enricher - only one platform instrumentor should be active at a time
16+
_span_enricher: Callable[[ReadableSpan], ReadableSpan] | None = None
17+
_enricher_lock = threading.Lock()
18+
19+
20+
def register_span_enricher(enricher: Callable[[ReadableSpan], ReadableSpan]) -> None:
21+
"""Register the span enricher for the active platform instrumentor.
22+
23+
Only one enricher can be registered at a time since auto-instrumentation
24+
is platform-specific (Semantic Kernel, LangChain, or OpenAI Agents).
25+
26+
Args:
27+
enricher: Function that takes a ReadableSpan and returns an enriched span.
28+
29+
Raises:
30+
RuntimeError: If an enricher is already registered.
31+
"""
32+
global _span_enricher
33+
with _enricher_lock:
34+
if _span_enricher is not None:
35+
raise RuntimeError(
36+
"A span enricher is already registered. "
37+
"Only one platform instrumentor can be active at a time."
38+
)
39+
_span_enricher = enricher
40+
logger.debug("Span enricher registered: %s", enricher.__name__)
41+
42+
43+
def unregister_span_enricher() -> None:
44+
"""Unregister the current span enricher.
45+
46+
Called during uninstrumentation to clean up.
47+
"""
48+
global _span_enricher
49+
with _enricher_lock:
50+
if _span_enricher is not None:
51+
logger.debug("Span enricher unregistered: %s", _span_enricher.__name__)
52+
_span_enricher = None
53+
54+
55+
def get_span_enricher() -> Callable[[ReadableSpan], ReadableSpan] | None:
56+
"""Get the currently registered span enricher.
57+
58+
Returns:
59+
The registered enricher function, or None if no enricher is registered.
60+
"""
61+
with _enricher_lock:
62+
return _span_enricher
63+
64+
65+
class _EnrichingBatchSpanProcessor(BatchSpanProcessor):
66+
"""BatchSpanProcessor that applies the registered enricher before batching."""
67+
68+
def on_end(self, span: ReadableSpan) -> None:
69+
"""Apply the span enricher and pass to parent for batching.
70+
71+
Args:
72+
span: The span that has ended.
73+
"""
74+
enriched_span = span
75+
76+
enricher = get_span_enricher()
77+
if enricher is not None:
78+
try:
79+
enriched_span = enricher(span)
80+
except Exception:
81+
logger.exception(
82+
"Span enricher %s raised an exception, using original span",
83+
enricher.__name__,
84+
)
85+
86+
super().on_end(enriched_span)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
"""Span enricher for Semantic Kernel."""
5+
6+
from microsoft_agents_a365.observability.core.constants import (
7+
EXECUTE_TOOL_OPERATION_NAME,
8+
GEN_AI_INPUT_MESSAGES_KEY,
9+
GEN_AI_OUTPUT_MESSAGES_KEY,
10+
GEN_AI_TOOL_ARGS_KEY,
11+
GEN_AI_TOOL_CALL_RESULT_KEY,
12+
INVOKE_AGENT_OPERATION_NAME,
13+
)
14+
from microsoft_agents_a365.observability.core.exporters.enriched_span import EnrichedReadableSpan
15+
from opentelemetry.sdk.trace import ReadableSpan
16+
17+
from .utils import extract_content_as_string_list
18+
19+
# Semantic Kernel specific attribute keys
20+
SK_TOOL_CALL_ARGUMENTS_KEY = "gen_ai.tool.call.arguments"
21+
SK_TOOL_CALL_RESULT_KEY = "gen_ai.tool.call.result"
22+
23+
24+
def enrich_semantic_kernel_span(span: ReadableSpan) -> ReadableSpan:
25+
"""
26+
Enricher function for Semantic Kernel spans.
27+
28+
Transforms SK-specific attributes to standard gen_ai attributes
29+
before the span is exported. Enrichment is applied based on span type:
30+
- invoke_agent spans: Extract only content from input/output messages
31+
- execute_tool spans: Map tool arguments and results to standard keys
32+
33+
Args:
34+
span: The ReadableSpan to enrich.
35+
36+
Returns:
37+
The enriched span (wrapped if attributes were added), or the
38+
original span if no enrichment was needed.
39+
"""
40+
extra_attributes = {}
41+
attributes = span.attributes or {}
42+
43+
# Only extract content for invoke_agent spans
44+
if span.name.startswith(INVOKE_AGENT_OPERATION_NAME):
45+
# Transform SK-specific agent invocation attributes to standard gen_ai attributes
46+
# Extract only the content from the full message objects
47+
# Support both gen_ai.agent.invocation_input and gen_ai.input_messages as sources
48+
input_messages = attributes.get("gen_ai.agent.invocation_input") or attributes.get(
49+
GEN_AI_INPUT_MESSAGES_KEY
50+
)
51+
if input_messages:
52+
extra_attributes[GEN_AI_INPUT_MESSAGES_KEY] = extract_content_as_string_list(
53+
input_messages
54+
)
55+
56+
output_messages = attributes.get("gen_ai.agent.invocation_output") or attributes.get(
57+
GEN_AI_OUTPUT_MESSAGES_KEY
58+
)
59+
if output_messages:
60+
extra_attributes[GEN_AI_OUTPUT_MESSAGES_KEY] = extract_content_as_string_list(
61+
output_messages
62+
)
63+
64+
# Map tool attributes for execute_tool spans
65+
elif span.name.startswith(EXECUTE_TOOL_OPERATION_NAME):
66+
if SK_TOOL_CALL_ARGUMENTS_KEY in attributes:
67+
extra_attributes[GEN_AI_TOOL_ARGS_KEY] = attributes[SK_TOOL_CALL_ARGUMENTS_KEY]
68+
69+
if SK_TOOL_CALL_RESULT_KEY in attributes:
70+
extra_attributes[GEN_AI_TOOL_CALL_RESULT_KEY] = attributes[SK_TOOL_CALL_RESULT_KEY]
71+
72+
if extra_attributes:
73+
return EnrichedReadableSpan(span, extra_attributes)
74+
75+
return span

0 commit comments

Comments
 (0)