respan-instrumentation-semantic-kernel 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,52 @@
1
+ Metadata-Version: 2.1
2
+ Name: respan-instrumentation-semantic-kernel
3
+ Version: 0.1.0
4
+ Summary: Respan instrumentation plugin for Microsoft Semantic Kernel
5
+ License: Apache 2.0
6
+ Author: Respan
7
+ Author-email: team@respan.ai
8
+ Requires-Python: >=3.11,<3.14
9
+ Classifier: License :: Other/Proprietary License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Requires-Dist: opentelemetry-semantic-conventions (>=0.59b0)
14
+ Requires-Dist: opentelemetry-semantic-conventions-ai (>=0.5.1)
15
+ Requires-Dist: respan-sdk (>=2.6.1)
16
+ Requires-Dist: respan-tracing (>=2.17.0,<3.0.0)
17
+ Requires-Dist: semantic-kernel (>=1.36.0)
18
+ Description-Content-Type: text/markdown
19
+
20
+ # respan-instrumentation-semantic-kernel
21
+
22
+ Respan instrumentation plugin for [Microsoft Semantic Kernel](https://github.com/microsoft/semantic-kernel).
23
+
24
+ Semantic Kernel Python already emits OpenTelemetry spans for kernel functions
25
+ and chat/text completions when its experimental diagnostics are enabled. This
26
+ package activates those diagnostics, captures Semantic Kernel prompt/completion
27
+ log records on the active completion span, and registers a Respan span processor
28
+ that normalizes the resulting spans to the Respan/OpenTelemetry contract.
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ pip install respan-ai respan-instrumentation-semantic-kernel
34
+ ```
35
+
36
+ ## Usage
37
+
38
+ ```python
39
+ from respan import Respan
40
+ from respan_instrumentation_semantic_kernel import SemanticKernelInstrumentor
41
+
42
+ respan = Respan(
43
+ instrumentations=[SemanticKernelInstrumentor()],
44
+ )
45
+ ```
46
+
47
+ Any Semantic Kernel work started after initialization is traced through the
48
+ active Respan OpenTelemetry pipeline. By default the instrumentor enables
49
+ Semantic Kernel's sensitive diagnostics so prompt and completion content can be
50
+ attached to chat/text completion spans. Pass `capture_content=False` to emit
51
+ model and token metadata without prompt/completion content.
52
+
@@ -0,0 +1,32 @@
1
+ # respan-instrumentation-semantic-kernel
2
+
3
+ Respan instrumentation plugin for [Microsoft Semantic Kernel](https://github.com/microsoft/semantic-kernel).
4
+
5
+ Semantic Kernel Python already emits OpenTelemetry spans for kernel functions
6
+ and chat/text completions when its experimental diagnostics are enabled. This
7
+ package activates those diagnostics, captures Semantic Kernel prompt/completion
8
+ log records on the active completion span, and registers a Respan span processor
9
+ that normalizes the resulting spans to the Respan/OpenTelemetry contract.
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ pip install respan-ai respan-instrumentation-semantic-kernel
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ ```python
20
+ from respan import Respan
21
+ from respan_instrumentation_semantic_kernel import SemanticKernelInstrumentor
22
+
23
+ respan = Respan(
24
+ instrumentations=[SemanticKernelInstrumentor()],
25
+ )
26
+ ```
27
+
28
+ Any Semantic Kernel work started after initialization is traced through the
29
+ active Respan OpenTelemetry pipeline. By default the instrumentor enables
30
+ Semantic Kernel's sensitive diagnostics so prompt and completion content can be
31
+ attached to chat/text completion spans. Pass `capture_content=False` to emit
32
+ model and token metadata without prompt/completion content.
@@ -0,0 +1,30 @@
1
+ [tool.poetry]
2
+ name = "respan-instrumentation-semantic-kernel"
3
+ version = "0.1.0"
4
+ description = "Respan instrumentation plugin for Microsoft Semantic Kernel"
5
+ authors = ["Respan <team@respan.ai>"]
6
+ license = "Apache 2.0"
7
+ readme = "README.md"
8
+ packages = [
9
+ { include = "respan_instrumentation_semantic_kernel", from = "./src" },
10
+ ]
11
+
12
+ [tool.poetry.dependencies]
13
+ python = ">=3.11,<3.14"
14
+ respan-tracing = "^2.17.0"
15
+ respan-sdk = ">=2.6.1"
16
+ semantic-kernel = ">=1.36.0"
17
+ opentelemetry-semantic-conventions-ai = ">=0.5.1"
18
+ opentelemetry-semantic-conventions = ">=0.59b0"
19
+
20
+ [tool.poetry.plugins."respan.instrumentations"]
21
+ semantic-kernel = "respan_instrumentation_semantic_kernel:SemanticKernelInstrumentor"
22
+
23
+ [tool.pytest.ini_options]
24
+ markers = [
25
+ "integration: live network tests (requires API keys)",
26
+ ]
27
+
28
+ [build-system]
29
+ requires = ["poetry-core"]
30
+ build-backend = "poetry.core.masonry.api"
@@ -0,0 +1,7 @@
1
+ """Respan instrumentation plugin for Semantic Kernel."""
2
+
3
+ from respan_instrumentation_semantic_kernel._instrumentation import (
4
+ SemanticKernelInstrumentor,
5
+ )
6
+
7
+ __all__ = ["SemanticKernelInstrumentor"]
@@ -0,0 +1,48 @@
1
+ """Semantic Kernel raw telemetry constants local to this instrumentation."""
2
+
3
+ from opentelemetry.semconv._incubating.attributes import (
4
+ gen_ai_attributes as GenAIAttributes,
5
+ )
6
+
7
+ SEMANTIC_KERNEL_INSTRUMENTATION_NAME = "semantic-kernel"
8
+ SEMANTIC_KERNEL_ROOT_MODULE = "semantic_kernel"
9
+ SEMANTIC_KERNEL_SCOPE_PREFIX = "semantic_kernel"
10
+ SEMANTIC_KERNEL_MODEL_DIAGNOSTICS_LOGGER = (
11
+ "semantic_kernel.utils.telemetry.model_diagnostics.decorators"
12
+ )
13
+
14
+ SEMANTIC_KERNEL_ENABLE_OTEL_DIAGNOSTICS_ENV = (
15
+ "SEMANTICKERNEL_EXPERIMENTAL_GENAI_ENABLE_OTEL_DIAGNOSTICS"
16
+ )
17
+ SEMANTIC_KERNEL_ENABLE_OTEL_DIAGNOSTICS_SENSITIVE_ENV = (
18
+ "SEMANTICKERNEL_EXPERIMENTAL_GENAI_ENABLE_OTEL_DIAGNOSTICS_SENSITIVE"
19
+ )
20
+
21
+ SEMANTIC_KERNEL_MODEL_DIAGNOSTICS_MODULES = (
22
+ "semantic_kernel.utils.telemetry.model_diagnostics.decorators",
23
+ "semantic_kernel.utils.telemetry.model_diagnostics.function_tracer",
24
+ )
25
+
26
+ SK_CHAT_COMPLETION_OPERATION = "chat.completions"
27
+ SK_CHAT_STREAMING_COMPLETION_OPERATION = "chat.streaming_completions"
28
+ SK_TEXT_COMPLETION_OPERATION = "text.completions"
29
+ SK_TEXT_STREAMING_COMPLETION_OPERATION = "text.streaming_completions"
30
+ SK_TOOL_OPERATION = "execute_tool"
31
+ SK_AUTO_FUNCTION_INVOCATION_SPAN_NAME = "AutoFunctionInvocationLoop"
32
+
33
+ SK_EVENT_NAME_ATTR = "event.name"
34
+ SK_CHAT_MESSAGE_INDEX_ATTR = "CHAT_MESSAGE_INDEX"
35
+ SK_SYSTEM_MESSAGE_EVENT = "gen_ai.system.message"
36
+ SK_USER_MESSAGE_EVENT = "gen_ai.user.message"
37
+ SK_ASSISTANT_MESSAGE_EVENT = "gen_ai.assistant.message"
38
+ SK_TOOL_MESSAGE_EVENT = "gen_ai.tool.message"
39
+ SK_PROMPT_EVENT = "gen_ai.prompt"
40
+ SK_CHOICE_EVENT = "gen_ai.choice"
41
+ SK_CONTENT_PROMPT_EVENT = "gen_ai.content.prompt"
42
+ SK_CONTENT_COMPLETION_EVENT = "gen_ai.content.completion"
43
+
44
+ SK_PROMPT_ATTR = GenAIAttributes.GEN_AI_PROMPT
45
+ SK_COMPLETION_ATTR = GenAIAttributes.GEN_AI_COMPLETION
46
+ SK_RESPONSE_PROMPT_TOKENS_ATTR = "gen_ai.response.prompt_tokens"
47
+ SK_RESPONSE_COMPLETION_TOKENS_ATTR = "gen_ai.response.completion_tokens"
48
+ SK_AVAILABLE_FUNCTIONS_ATTR = "sk.available_functions"
@@ -0,0 +1,169 @@
1
+ """Semantic Kernel instrumentation plugin for Respan."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import logging
7
+ import os
8
+ from typing import Any
9
+
10
+ from opentelemetry import trace
11
+
12
+ from respan_instrumentation_semantic_kernel._constants import (
13
+ SEMANTIC_KERNEL_ENABLE_OTEL_DIAGNOSTICS_ENV,
14
+ SEMANTIC_KERNEL_ENABLE_OTEL_DIAGNOSTICS_SENSITIVE_ENV,
15
+ SEMANTIC_KERNEL_INSTRUMENTATION_NAME,
16
+ SEMANTIC_KERNEL_MODEL_DIAGNOSTICS_LOGGER,
17
+ SEMANTIC_KERNEL_MODEL_DIAGNOSTICS_MODULES,
18
+ SEMANTIC_KERNEL_ROOT_MODULE,
19
+ )
20
+ from respan_instrumentation_semantic_kernel._processor import (
21
+ SemanticKernelLogRecordHandler,
22
+ SemanticKernelSpanProcessor,
23
+ insert_span_processor_before_export,
24
+ remove_span_processor,
25
+ )
26
+ from respan_tracing.core.tracer import RespanTracer
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ _UNSET = object()
31
+
32
+
33
+ class SemanticKernelInstrumentor:
34
+ """Respan instrumentor for Microsoft Semantic Kernel Python."""
35
+
36
+ name = SEMANTIC_KERNEL_INSTRUMENTATION_NAME
37
+
38
+ def __init__(self, *, capture_content: bool = True) -> None:
39
+ self._capture_content = capture_content
40
+ self._processor: SemanticKernelSpanProcessor | None = None
41
+ self._log_handler: SemanticKernelLogRecordHandler | None = None
42
+ self._is_instrumented = False
43
+ self._previous_env: dict[str, str | object] = {}
44
+ self._previous_settings: list[tuple[Any, str, Any]] = []
45
+ self._diagnostics_logger: logging.Logger | None = None
46
+ self._previous_logger_level: int | None = None
47
+
48
+ @staticmethod
49
+ def _is_respan_tracing_enabled() -> bool:
50
+ tracer = getattr(RespanTracer, "_instance", None)
51
+ if tracer is None:
52
+ return True
53
+ return bool(getattr(tracer, "is_enabled", True))
54
+
55
+ def activate(self) -> None:
56
+ """Activate Semantic Kernel diagnostics and Respan span normalization."""
57
+ if self._is_instrumented:
58
+ return
59
+
60
+ if not self._is_respan_tracing_enabled():
61
+ logger.info(
62
+ "Semantic Kernel instrumentation skipped because Respan tracing is disabled"
63
+ )
64
+ return
65
+
66
+ try:
67
+ importlib.import_module(SEMANTIC_KERNEL_ROOT_MODULE)
68
+ except ImportError as exc:
69
+ logger.warning(
70
+ "Failed to activate Semantic Kernel instrumentation - missing dependency: %s",
71
+ exc,
72
+ )
73
+ return
74
+
75
+ tracer_provider = trace.get_tracer_provider()
76
+ try:
77
+ self._enable_semantic_kernel_diagnostics()
78
+ self._processor = SemanticKernelSpanProcessor()
79
+ insert_span_processor_before_export(tracer_provider, self._processor)
80
+ self._install_log_handler()
81
+ self._is_instrumented = True
82
+ logger.info("Semantic Kernel instrumentation activated")
83
+ except Exception:
84
+ if self._processor is not None:
85
+ remove_span_processor(tracer_provider, self._processor)
86
+ self._processor = None
87
+ self._remove_log_handler()
88
+ self._restore_semantic_kernel_diagnostics()
89
+ self._is_instrumented = False
90
+ logger.exception("Failed to activate Semantic Kernel instrumentation")
91
+
92
+ def deactivate(self) -> None:
93
+ """Deactivate the instrumentation and restore local diagnostics settings."""
94
+ tracer_provider = trace.get_tracer_provider()
95
+ if self._processor is not None:
96
+ remove_span_processor(tracer_provider, self._processor)
97
+ self._processor = None
98
+ self._remove_log_handler()
99
+ self._restore_semantic_kernel_diagnostics()
100
+ self._is_instrumented = False
101
+ logger.info("Semantic Kernel instrumentation deactivated")
102
+
103
+ def _enable_semantic_kernel_diagnostics(self) -> None:
104
+ self._set_env(SEMANTIC_KERNEL_ENABLE_OTEL_DIAGNOSTICS_ENV, "true")
105
+ self._set_env(
106
+ SEMANTIC_KERNEL_ENABLE_OTEL_DIAGNOSTICS_SENSITIVE_ENV,
107
+ "true" if self._capture_content else "false",
108
+ )
109
+
110
+ for module_name in SEMANTIC_KERNEL_MODEL_DIAGNOSTICS_MODULES:
111
+ try:
112
+ module = importlib.import_module(module_name)
113
+ except ImportError:
114
+ continue
115
+
116
+ settings = getattr(module, "MODEL_DIAGNOSTICS_SETTINGS", None)
117
+ if settings is None:
118
+ continue
119
+ self._set_setting(settings, "enable_otel_diagnostics", True)
120
+ self._set_setting(
121
+ settings,
122
+ "enable_otel_diagnostics_sensitive",
123
+ self._capture_content,
124
+ )
125
+
126
+ def _restore_semantic_kernel_diagnostics(self) -> None:
127
+ for settings, field_name, previous_value in reversed(self._previous_settings):
128
+ setattr(settings, field_name, previous_value)
129
+ self._previous_settings = []
130
+
131
+ for env_name, previous_value in self._previous_env.items():
132
+ if previous_value is _UNSET:
133
+ os.environ.pop(env_name, None)
134
+ else:
135
+ os.environ[env_name] = str(previous_value)
136
+ self._previous_env = {}
137
+
138
+ def _set_env(self, name: str, value: str) -> None:
139
+ if name not in self._previous_env:
140
+ self._previous_env[name] = os.environ.get(name, _UNSET)
141
+ os.environ[name] = value
142
+
143
+ def _set_setting(self, settings: Any, field_name: str, value: bool) -> None:
144
+ self._previous_settings.append(
145
+ (settings, field_name, getattr(settings, field_name))
146
+ )
147
+ setattr(settings, field_name, value)
148
+
149
+ def _install_log_handler(self) -> None:
150
+ self._diagnostics_logger = logging.getLogger(
151
+ SEMANTIC_KERNEL_MODEL_DIAGNOSTICS_LOGGER
152
+ )
153
+ self._previous_logger_level = self._diagnostics_logger.level
154
+ self._diagnostics_logger.setLevel(logging.INFO)
155
+ self._log_handler = SemanticKernelLogRecordHandler()
156
+ self._log_handler.setLevel(logging.INFO)
157
+ self._diagnostics_logger.addHandler(self._log_handler)
158
+
159
+ def _remove_log_handler(self) -> None:
160
+ if self._diagnostics_logger is not None and self._log_handler is not None:
161
+ self._diagnostics_logger.removeHandler(self._log_handler)
162
+ if (
163
+ self._diagnostics_logger is not None
164
+ and self._previous_logger_level is not None
165
+ ):
166
+ self._diagnostics_logger.setLevel(self._previous_logger_level)
167
+ self._diagnostics_logger = None
168
+ self._previous_logger_level = None
169
+ self._log_handler = None
@@ -0,0 +1,623 @@
1
+ """Semantic Kernel span normalization for the Respan OTLP pipeline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from collections.abc import Callable, Mapping
8
+ from typing import Any
9
+
10
+ from opentelemetry import trace
11
+ from opentelemetry.context import Context
12
+ from opentelemetry.sdk.trace import ReadableSpan, Span, SpanProcessor
13
+ from opentelemetry.semconv._incubating.attributes import gen_ai_attributes as GenAIAttributes
14
+ from opentelemetry.semconv_ai import LLMRequestTypeValues, SpanAttributes
15
+
16
+ from respan_instrumentation_semantic_kernel._constants import (
17
+ SEMANTIC_KERNEL_SCOPE_PREFIX,
18
+ SK_ASSISTANT_MESSAGE_EVENT,
19
+ SK_AVAILABLE_FUNCTIONS_ATTR,
20
+ SK_CHAT_COMPLETION_OPERATION,
21
+ SK_CHAT_MESSAGE_INDEX_ATTR,
22
+ SK_CHAT_STREAMING_COMPLETION_OPERATION,
23
+ SK_CHOICE_EVENT,
24
+ SK_COMPLETION_ATTR,
25
+ SK_CONTENT_COMPLETION_EVENT,
26
+ SK_CONTENT_PROMPT_EVENT,
27
+ SK_EVENT_NAME_ATTR,
28
+ SK_PROMPT_ATTR,
29
+ SK_PROMPT_EVENT,
30
+ SK_RESPONSE_COMPLETION_TOKENS_ATTR,
31
+ SK_RESPONSE_PROMPT_TOKENS_ATTR,
32
+ SK_SYSTEM_MESSAGE_EVENT,
33
+ SK_TEXT_COMPLETION_OPERATION,
34
+ SK_TEXT_STREAMING_COMPLETION_OPERATION,
35
+ SK_TOOL_MESSAGE_EVENT,
36
+ SK_TOOL_OPERATION,
37
+ SK_USER_MESSAGE_EVENT,
38
+ )
39
+ from respan_sdk.constants.llm_logging import (
40
+ LOG_TYPE_CHAT,
41
+ LOG_TYPE_TEXT,
42
+ LOG_TYPE_TOOL,
43
+ LogMethodChoices,
44
+ )
45
+ from respan_sdk.constants.span_attributes import (
46
+ RESPAN_LOG_METHOD,
47
+ RESPAN_LOG_TYPE,
48
+ RESPAN_SPAN_HANDOFFS,
49
+ RESPAN_SPAN_TOOL_CALLS,
50
+ RESPAN_SPAN_TOOLS,
51
+ )
52
+
53
+ logger = logging.getLogger(__name__)
54
+
55
+ _CHAT_OPERATIONS = {
56
+ SK_CHAT_COMPLETION_OPERATION,
57
+ SK_CHAT_STREAMING_COMPLETION_OPERATION,
58
+ }
59
+ _TEXT_OPERATIONS = {
60
+ SK_TEXT_COMPLETION_OPERATION,
61
+ SK_TEXT_STREAMING_COMPLETION_OPERATION,
62
+ }
63
+ _PROMPT_MESSAGE_EVENTS = {
64
+ SK_SYSTEM_MESSAGE_EVENT: "system",
65
+ SK_USER_MESSAGE_EVENT: "user",
66
+ SK_ASSISTANT_MESSAGE_EVENT: "assistant",
67
+ SK_TOOL_MESSAGE_EVENT: "tool",
68
+ }
69
+ _OFF_CONTRACT_ALIASES = {
70
+ RESPAN_SPAN_TOOLS,
71
+ RESPAN_SPAN_TOOL_CALLS,
72
+ RESPAN_SPAN_HANDOFFS,
73
+ "tools",
74
+ "tool_calls",
75
+ "model",
76
+ "prompt_tokens",
77
+ "completion_tokens",
78
+ "total_request_tokens",
79
+ "span_tools",
80
+ "has_tool_calls",
81
+ "parallel_tool_calls",
82
+ }
83
+
84
+
85
+ def _safe_json_loads(value: Any) -> Any:
86
+ if not isinstance(value, str):
87
+ return value
88
+ try:
89
+ return json.loads(value)
90
+ except (TypeError, json.JSONDecodeError):
91
+ return value
92
+
93
+
94
+ def _json_string(value: Any) -> str | None:
95
+ if value is None:
96
+ return None
97
+ if isinstance(value, str):
98
+ return value
99
+ return json.dumps(value, default=str)
100
+
101
+
102
+ def _as_int(value: Any) -> int | None:
103
+ if isinstance(value, bool):
104
+ return None
105
+ if isinstance(value, int):
106
+ return value
107
+ return None
108
+
109
+
110
+ def _scope_name(span: ReadableSpan) -> str:
111
+ scope = getattr(span, "instrumentation_scope", None)
112
+ if scope is not None:
113
+ name = getattr(scope, "name", "")
114
+ if name:
115
+ return name
116
+ instrumentation_info = getattr(span, "instrumentation_info", None)
117
+ return getattr(instrumentation_info, "name", "") or ""
118
+
119
+
120
+ def _span_attrs(span: Any) -> dict[str, Any]:
121
+ attrs = getattr(span, "_attributes", None)
122
+ if attrs is not None:
123
+ return dict(attrs)
124
+ return dict(getattr(span, "attributes", None) or {})
125
+
126
+
127
+ def _is_semantic_kernel_span(span: ReadableSpan) -> bool:
128
+ return _scope_name(span).startswith(SEMANTIC_KERNEL_SCOPE_PREFIX)
129
+
130
+
131
+ def _normalize_role(role: Any, fallback: str = "user") -> str:
132
+ if hasattr(role, "value"):
133
+ role = role.value
134
+ if not isinstance(role, str) or not role:
135
+ return fallback
136
+ normalized = role.lower()
137
+ if normalized == "model":
138
+ return "assistant"
139
+ return normalized
140
+
141
+
142
+ def _message_content(message: Mapping[str, Any]) -> Any:
143
+ content = message.get("content")
144
+ if content is not None:
145
+ return content
146
+
147
+ items = message.get("items")
148
+ if not isinstance(items, list):
149
+ return None
150
+
151
+ text_parts: list[str] = []
152
+ for item in items:
153
+ if not isinstance(item, Mapping):
154
+ continue
155
+ text = item.get("text") or item.get("content")
156
+ if text is not None:
157
+ text_parts.append(str(text))
158
+ return "\n".join(text_parts) if text_parts else None
159
+
160
+
161
+ def _message_tool_calls(message: Mapping[str, Any]) -> Any:
162
+ tool_calls = message.get("tool_calls")
163
+ if tool_calls is not None:
164
+ return tool_calls
165
+
166
+ items = message.get("items")
167
+ if not isinstance(items, list):
168
+ return None
169
+
170
+ calls: list[dict[str, Any]] = []
171
+ for item in items:
172
+ if not isinstance(item, Mapping):
173
+ continue
174
+ name = item.get("name")
175
+ arguments = item.get("arguments")
176
+ if name is None and arguments is None:
177
+ continue
178
+ function_payload: dict[str, Any] = {}
179
+ if name is not None:
180
+ function_payload["name"] = name
181
+ if arguments is not None:
182
+ function_payload["arguments"] = arguments
183
+ call: dict[str, Any] = {"type": "function", "function": function_payload}
184
+ if item.get("id") is not None:
185
+ call["id"] = item["id"]
186
+ calls.append(call)
187
+ return calls or None
188
+
189
+
190
+ def _set_message_attrs(
191
+ set_attr: Callable[[str, Any], None],
192
+ *,
193
+ prefix: str,
194
+ index: int,
195
+ message: Mapping[str, Any],
196
+ fallback_role: str,
197
+ ) -> None:
198
+ target = f"{prefix}.{index}"
199
+ set_attr(f"{target}.role", _normalize_role(message.get("role"), fallback_role))
200
+
201
+ content = _message_content(message)
202
+ if content is not None:
203
+ set_attr(f"{target}.content", _json_string(content))
204
+
205
+ tool_calls = _message_tool_calls(message)
206
+ if tool_calls is not None:
207
+ set_attr(f"{target}.tool_calls", _json_string(tool_calls))
208
+
209
+
210
+ def _promote_messages(
211
+ attrs: dict[str, Any],
212
+ *,
213
+ prefix: str,
214
+ messages: Any,
215
+ fallback_role: str,
216
+ ) -> None:
217
+ parsed = _safe_json_loads(messages)
218
+ if isinstance(parsed, Mapping) and isinstance(parsed.get("message"), Mapping):
219
+ parsed = parsed["message"]
220
+ if isinstance(parsed, Mapping):
221
+ parsed = [parsed]
222
+ if not isinstance(parsed, list):
223
+ return
224
+
225
+ for index, item in enumerate(parsed):
226
+ if isinstance(item, Mapping) and isinstance(item.get("message"), Mapping):
227
+ item = item["message"]
228
+ if not isinstance(item, Mapping):
229
+ continue
230
+ _set_message_attrs(
231
+ attrs.__setitem__,
232
+ prefix=prefix,
233
+ index=index,
234
+ message=item,
235
+ fallback_role=fallback_role,
236
+ )
237
+
238
+
239
+ def _promote_legacy_event_payloads(span: ReadableSpan, attrs: dict[str, Any]) -> None:
240
+ for event in getattr(span, "events", ()) or ():
241
+ event_attrs = dict(getattr(event, "attributes", None) or {})
242
+ event_name = getattr(event, "name", "")
243
+ if event_name == SK_CONTENT_PROMPT_EVENT and SK_PROMPT_ATTR in event_attrs:
244
+ _promote_messages(
245
+ attrs,
246
+ prefix=SpanAttributes.LLM_PROMPTS,
247
+ messages=event_attrs[SK_PROMPT_ATTR],
248
+ fallback_role="user",
249
+ )
250
+ if event_name == SK_CONTENT_COMPLETION_EVENT and SK_COMPLETION_ATTR in event_attrs:
251
+ _promote_messages(
252
+ attrs,
253
+ prefix=SpanAttributes.LLM_COMPLETIONS,
254
+ messages=event_attrs[SK_COMPLETION_ATTR],
255
+ fallback_role="assistant",
256
+ )
257
+
258
+
259
+ def _collect_indexed_messages(attrs: Mapping[str, Any], prefix: str) -> list[dict[str, Any]]:
260
+ buckets: dict[int, dict[str, Any]] = {}
261
+ indexed_prefix = f"{prefix}."
262
+ for key, value in attrs.items():
263
+ if not key.startswith(indexed_prefix):
264
+ continue
265
+ rest = key[len(indexed_prefix):]
266
+ index_text, _, field = rest.partition(".")
267
+ if not index_text.isdigit() or not field:
268
+ continue
269
+ buckets.setdefault(int(index_text), {})[field] = value
270
+
271
+ messages: list[dict[str, Any]] = []
272
+ for index in sorted(buckets):
273
+ raw = buckets[index]
274
+ message: dict[str, Any] = {}
275
+ if raw.get("role") is not None:
276
+ message["role"] = raw["role"]
277
+ if raw.get("content") is not None:
278
+ message["content"] = raw["content"]
279
+ if raw.get("tool_calls") is not None:
280
+ message["tool_calls"] = _safe_json_loads(raw["tool_calls"])
281
+ if message:
282
+ messages.append(message)
283
+ return messages
284
+
285
+
286
+ def _normalize_available_functions(value: Any) -> list[dict[str, Any]] | None:
287
+ parsed = _safe_json_loads(value)
288
+ if isinstance(parsed, str):
289
+ names = [name.strip() for name in parsed.split(",") if name.strip()]
290
+ elif isinstance(parsed, list):
291
+ names = [str(name) for name in parsed if isinstance(name, str) and name]
292
+ else:
293
+ return None
294
+
295
+ return [
296
+ {
297
+ "type": "function",
298
+ "function": {"name": name},
299
+ }
300
+ for name in names
301
+ ] or None
302
+
303
+
304
+ def _map_usage(attrs: dict[str, Any]) -> None:
305
+ input_tokens = (
306
+ _as_int(attrs.get(GenAIAttributes.GEN_AI_USAGE_INPUT_TOKENS))
307
+ or _as_int(attrs.get(SK_RESPONSE_PROMPT_TOKENS_ATTR))
308
+ or _as_int(attrs.get(SpanAttributes.LLM_USAGE_PROMPT_TOKENS))
309
+ )
310
+ output_tokens = (
311
+ _as_int(attrs.get(GenAIAttributes.GEN_AI_USAGE_OUTPUT_TOKENS))
312
+ or _as_int(attrs.get(SK_RESPONSE_COMPLETION_TOKENS_ATTR))
313
+ or _as_int(attrs.get(SpanAttributes.LLM_USAGE_COMPLETION_TOKENS))
314
+ )
315
+
316
+ if input_tokens is not None:
317
+ attrs[GenAIAttributes.GEN_AI_USAGE_INPUT_TOKENS] = input_tokens
318
+ attrs[SpanAttributes.LLM_USAGE_PROMPT_TOKENS] = input_tokens
319
+ if output_tokens is not None:
320
+ attrs[GenAIAttributes.GEN_AI_USAGE_OUTPUT_TOKENS] = output_tokens
321
+ attrs[SpanAttributes.LLM_USAGE_COMPLETION_TOKENS] = output_tokens
322
+ if (
323
+ input_tokens is not None
324
+ and output_tokens is not None
325
+ and SpanAttributes.LLM_USAGE_TOTAL_TOKENS not in attrs
326
+ ):
327
+ attrs[SpanAttributes.LLM_USAGE_TOTAL_TOKENS] = input_tokens + output_tokens
328
+
329
+
330
+ def _map_completion_span(
331
+ span: ReadableSpan,
332
+ attrs: dict[str, Any],
333
+ *,
334
+ log_type: str,
335
+ request_type: str,
336
+ ) -> None:
337
+ if SK_PROMPT_ATTR in attrs:
338
+ _promote_messages(
339
+ attrs,
340
+ prefix=SpanAttributes.LLM_PROMPTS,
341
+ messages=attrs[SK_PROMPT_ATTR],
342
+ fallback_role="user",
343
+ )
344
+ if SK_COMPLETION_ATTR in attrs:
345
+ _promote_messages(
346
+ attrs,
347
+ prefix=SpanAttributes.LLM_COMPLETIONS,
348
+ messages=attrs[SK_COMPLETION_ATTR],
349
+ fallback_role="assistant",
350
+ )
351
+ _promote_legacy_event_payloads(span, attrs)
352
+
353
+ attrs[RESPAN_LOG_TYPE] = log_type
354
+ attrs[RESPAN_LOG_METHOD] = LogMethodChoices.TRACING_INTEGRATION.value
355
+ attrs[SpanAttributes.LLM_REQUEST_TYPE] = request_type
356
+ attrs.setdefault(SpanAttributes.TRACELOOP_ENTITY_NAME, getattr(span, "name", ""))
357
+ attrs.setdefault(SpanAttributes.TRACELOOP_ENTITY_PATH, "")
358
+ _map_usage(attrs)
359
+
360
+ prompt_messages = _collect_indexed_messages(attrs, SpanAttributes.LLM_PROMPTS)
361
+ if prompt_messages:
362
+ attrs.setdefault(SpanAttributes.TRACELOOP_ENTITY_INPUT, _json_string(prompt_messages))
363
+
364
+ completion_messages = _collect_indexed_messages(attrs, SpanAttributes.LLM_COMPLETIONS)
365
+ if completion_messages:
366
+ attrs.setdefault(
367
+ SpanAttributes.TRACELOOP_ENTITY_OUTPUT,
368
+ _json_string(completion_messages),
369
+ )
370
+
371
+ available_functions = _normalize_available_functions(
372
+ attrs.get(SK_AVAILABLE_FUNCTIONS_ATTR)
373
+ )
374
+ if available_functions is not None:
375
+ attrs.setdefault(
376
+ SpanAttributes.LLM_REQUEST_FUNCTIONS,
377
+ _json_string(available_functions),
378
+ )
379
+
380
+
381
+ def _tool_name_from_span(span: ReadableSpan, attrs: Mapping[str, Any]) -> str:
382
+ value = attrs.get(GenAIAttributes.GEN_AI_TOOL_NAME)
383
+ if isinstance(value, str) and value:
384
+ return value
385
+ name = getattr(span, "name", "")
386
+ prefix = f"{SK_TOOL_OPERATION} "
387
+ if isinstance(name, str) and name.startswith(prefix):
388
+ return name[len(prefix):]
389
+ return str(name or SK_TOOL_OPERATION)
390
+
391
+
392
+ def _map_tool_span(span: ReadableSpan, attrs: dict[str, Any]) -> None:
393
+ tool_name = _tool_name_from_span(span, attrs)
394
+ attrs[RESPAN_LOG_TYPE] = LOG_TYPE_TOOL
395
+ attrs[RESPAN_LOG_METHOD] = LogMethodChoices.TRACING_INTEGRATION.value
396
+ attrs[SpanAttributes.TRACELOOP_ENTITY_NAME] = tool_name
397
+ attrs[SpanAttributes.TRACELOOP_ENTITY_PATH] = tool_name
398
+
399
+ arguments = attrs.get(GenAIAttributes.GEN_AI_TOOL_CALL_ARGUMENTS)
400
+ if arguments is not None:
401
+ attrs[SpanAttributes.TRACELOOP_ENTITY_INPUT] = _json_string(arguments)
402
+
403
+ result = attrs.get(GenAIAttributes.GEN_AI_TOOL_CALL_RESULT)
404
+ if result is not None:
405
+ attrs[SpanAttributes.TRACELOOP_ENTITY_OUTPUT] = _json_string(result)
406
+
407
+ for key in (
408
+ GenAIAttributes.GEN_AI_TOOL_CALL_ARGUMENTS,
409
+ GenAIAttributes.GEN_AI_TOOL_CALL_ID,
410
+ GenAIAttributes.GEN_AI_TOOL_CALL_RESULT,
411
+ GenAIAttributes.GEN_AI_TOOL_DESCRIPTION,
412
+ GenAIAttributes.GEN_AI_TOOL_NAME,
413
+ GenAIAttributes.GEN_AI_TOOL_TYPE,
414
+ ):
415
+ attrs.pop(key, None)
416
+
417
+
418
+ def _cleanup_attrs(attrs: dict[str, Any]) -> None:
419
+ attrs.pop(SpanAttributes.TRACELOOP_SPAN_KIND, None)
420
+ attrs.pop(SK_PROMPT_ATTR, None)
421
+ attrs.pop(SK_COMPLETION_ATTR, None)
422
+ attrs.pop(SK_RESPONSE_PROMPT_TOKENS_ATTR, None)
423
+ attrs.pop(SK_RESPONSE_COMPLETION_TOKENS_ATTR, None)
424
+ attrs.pop(SK_AVAILABLE_FUNCTIONS_ATTR, None)
425
+ for key in _OFF_CONTRACT_ALIASES:
426
+ attrs.pop(key, None)
427
+
428
+
429
+ def enrich_semantic_kernel_span(span: ReadableSpan) -> bool:
430
+ """Normalize a Semantic Kernel span in place.
431
+
432
+ Returns ``True`` when the span belonged to Semantic Kernel and was handled.
433
+ """
434
+
435
+ if not _is_semantic_kernel_span(span):
436
+ return False
437
+
438
+ attrs = _span_attrs(span)
439
+ operation = attrs.get(GenAIAttributes.GEN_AI_OPERATION_NAME)
440
+
441
+ if operation in _CHAT_OPERATIONS:
442
+ _map_completion_span(
443
+ span,
444
+ attrs,
445
+ log_type=LOG_TYPE_CHAT,
446
+ request_type=LLMRequestTypeValues.CHAT.value,
447
+ )
448
+ elif operation in _TEXT_OPERATIONS:
449
+ _map_completion_span(
450
+ span,
451
+ attrs,
452
+ log_type=LOG_TYPE_TEXT,
453
+ request_type=LLMRequestTypeValues.COMPLETION.value,
454
+ )
455
+ elif operation == SK_TOOL_OPERATION:
456
+ _map_tool_span(span, attrs)
457
+ else:
458
+ return False
459
+
460
+ _cleanup_attrs(attrs)
461
+ span._attributes = attrs
462
+ return True
463
+
464
+
465
+ class SemanticKernelLogRecordHandler(logging.Handler):
466
+ """Attach Semantic Kernel diagnostic log payloads to the active span."""
467
+
468
+ def emit(self, record: logging.LogRecord) -> None:
469
+ try:
470
+ event_name = record.__dict__.get(SK_EVENT_NAME_ATTR)
471
+ if not isinstance(event_name, str):
472
+ return
473
+
474
+ span = trace.get_current_span()
475
+ if span is None or not hasattr(span, "set_attribute"):
476
+ return
477
+ is_recording = getattr(span, "is_recording", None)
478
+ if callable(is_recording) and not is_recording():
479
+ return
480
+
481
+ if event_name in _PROMPT_MESSAGE_EVENTS:
482
+ self._record_prompt_message(
483
+ span,
484
+ record,
485
+ fallback_role=_PROMPT_MESSAGE_EVENTS[event_name],
486
+ )
487
+ elif event_name == SK_PROMPT_EVENT:
488
+ self._record_prompt_text(span, record)
489
+ elif event_name == SK_CHOICE_EVENT:
490
+ self._record_completion_choice(span, record)
491
+ except Exception:
492
+ logger.debug("Failed to attach Semantic Kernel log record", exc_info=True)
493
+
494
+ @staticmethod
495
+ def _record_prompt_message(
496
+ span: Any,
497
+ record: logging.LogRecord,
498
+ *,
499
+ fallback_role: str,
500
+ ) -> None:
501
+ payload = _safe_json_loads(record.getMessage())
502
+ if not isinstance(payload, Mapping):
503
+ payload = {"role": fallback_role, "content": record.getMessage()}
504
+
505
+ index = getattr(record, SK_CHAT_MESSAGE_INDEX_ATTR, 0)
506
+ if not isinstance(index, int):
507
+ index = 0
508
+ _set_message_attrs(
509
+ span.set_attribute,
510
+ prefix=SpanAttributes.LLM_PROMPTS,
511
+ index=index,
512
+ message=payload,
513
+ fallback_role=fallback_role,
514
+ )
515
+
516
+ @staticmethod
517
+ def _record_prompt_text(span: Any, record: logging.LogRecord) -> None:
518
+ _set_message_attrs(
519
+ span.set_attribute,
520
+ prefix=SpanAttributes.LLM_PROMPTS,
521
+ index=0,
522
+ message={"role": "user", "content": record.getMessage()},
523
+ fallback_role="user",
524
+ )
525
+
526
+ @staticmethod
527
+ def _record_completion_choice(span: Any, record: logging.LogRecord) -> None:
528
+ payload = _safe_json_loads(record.getMessage())
529
+ if not isinstance(payload, Mapping):
530
+ payload = {"message": {"role": "assistant", "content": record.getMessage()}}
531
+
532
+ message = payload.get("message")
533
+ if not isinstance(message, Mapping):
534
+ message = payload
535
+
536
+ index = payload.get("index", 0)
537
+ if not isinstance(index, int):
538
+ index = 0
539
+ _set_message_attrs(
540
+ span.set_attribute,
541
+ prefix=SpanAttributes.LLM_COMPLETIONS,
542
+ index=index,
543
+ message=message,
544
+ fallback_role="assistant",
545
+ )
546
+
547
+
548
+ class SemanticKernelSpanProcessor(SpanProcessor):
549
+ """Translate Semantic Kernel spans to the Respan span contract."""
550
+
551
+ def on_start(
552
+ self,
553
+ span: Span,
554
+ parent_context: Context | None = None,
555
+ ) -> None:
556
+ return None
557
+
558
+ def on_end(self, span: ReadableSpan) -> None:
559
+ enrich_semantic_kernel_span(span)
560
+
561
+ def shutdown(self) -> None:
562
+ return None
563
+
564
+ def force_flush(self, timeout_millis: int = 30000) -> bool:
565
+ return True
566
+
567
+
568
+ def _is_export_processor(processor: Any) -> bool:
569
+ class_name = processor.__class__.__name__
570
+ module_name = processor.__class__.__module__
571
+ return class_name in {
572
+ "BufferingSpanProcessor",
573
+ "FilteringSpanProcessor",
574
+ "SimpleSpanProcessor",
575
+ "BatchSpanProcessor",
576
+ } or module_name.startswith("respan_tracing.processors")
577
+
578
+
579
+ def insert_span_processor_before_export(
580
+ tracer_provider: Any,
581
+ processor: SpanProcessor,
582
+ ) -> None:
583
+ active_processor = getattr(tracer_provider, "_active_span_processor", None)
584
+ processors = (
585
+ getattr(active_processor, "_span_processors", None)
586
+ if active_processor is not None
587
+ else None
588
+ )
589
+
590
+ if processors is None:
591
+ tracer_provider.add_span_processor(processor)
592
+ return
593
+
594
+ if processor in processors:
595
+ return
596
+
597
+ insert_index = len(processors)
598
+ for index, existing_processor in enumerate(processors):
599
+ if _is_export_processor(existing_processor):
600
+ insert_index = index
601
+ break
602
+
603
+ active_processor._span_processors = (
604
+ *processors[:insert_index],
605
+ processor,
606
+ *processors[insert_index:],
607
+ )
608
+
609
+
610
+ def remove_span_processor(tracer_provider: Any, processor: SpanProcessor) -> None:
611
+ active_processor = getattr(tracer_provider, "_active_span_processor", None)
612
+ processors = (
613
+ getattr(active_processor, "_span_processors", None)
614
+ if active_processor is not None
615
+ else None
616
+ )
617
+ if processors is None:
618
+ return
619
+ active_processor._span_processors = tuple(
620
+ existing_processor
621
+ for existing_processor in processors
622
+ if existing_processor is not processor
623
+ )