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.
- respan_instrumentation_semantic_kernel-0.1.0/PKG-INFO +52 -0
- respan_instrumentation_semantic_kernel-0.1.0/README.md +32 -0
- respan_instrumentation_semantic_kernel-0.1.0/pyproject.toml +30 -0
- respan_instrumentation_semantic_kernel-0.1.0/src/respan_instrumentation_semantic_kernel/__init__.py +7 -0
- respan_instrumentation_semantic_kernel-0.1.0/src/respan_instrumentation_semantic_kernel/_constants.py +48 -0
- respan_instrumentation_semantic_kernel-0.1.0/src/respan_instrumentation_semantic_kernel/_instrumentation.py +169 -0
- respan_instrumentation_semantic_kernel-0.1.0/src/respan_instrumentation_semantic_kernel/_processor.py +623 -0
|
@@ -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,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
|
+
)
|