|
50 | 50 | validate_transformer_rules, |
51 | 51 | ) |
52 | 52 | from ioa_observe.sdk.tracing.topology import ( |
| 53 | + next_session_event_version, |
53 | 54 | record_session_completed, |
54 | 55 | record_session_started, |
55 | 56 | ) |
| 57 | +from ioa_observe.sdk.tracing.runtime_event_emitter import emit_runtime_event |
| 58 | +from ioa_observe.sdk.tracing.runtime_events import ( |
| 59 | + RuntimeEventAttribute, |
| 60 | + RuntimeEventName, |
| 61 | + build_runtime_event_attributes, |
| 62 | +) |
56 | 63 | from ioa_observe.sdk.utils import is_notebook |
57 | 64 | from ioa_observe.sdk.client import kv_store |
58 | 65 |
|
@@ -160,6 +167,10 @@ def __new__( |
160 | 167 | # Track spans that have been processed to avoid duplicates |
161 | 168 | obj._processed_spans = set() |
162 | 169 | obj._processed_spans_lock = threading.Lock() |
| 170 | + obj._active_llm_spans: dict[ |
| 171 | + int, tuple[str, str, str | None, str | None] |
| 172 | + ] = {} |
| 173 | + obj._active_llm_spans_lock = threading.Lock() |
163 | 174 | TracerWrapper.app_name = TracerWrapper.resource_attributes.get( |
164 | 175 | "service.name", "observe" |
165 | 176 | ) |
@@ -438,6 +449,19 @@ def _span_processor_on_start(self, span, parent_context): |
438 | 449 |
|
439 | 450 | if is_llm_span(span): |
440 | 451 | self.llm_call_counter.add(1, attributes=span.attributes) |
| 452 | + if session_id is not None: |
| 453 | + llm_runtime_context = _llm_runtime_context( |
| 454 | + span, |
| 455 | + session_id=str(session_id), |
| 456 | + agent_name=workflow_name, |
| 457 | + ) |
| 458 | + with self._active_llm_spans_lock: |
| 459 | + self._active_llm_spans[span.context.span_id] = llm_runtime_context |
| 460 | + _emit_llm_started_runtime_event( |
| 461 | + span, |
| 462 | + llm_runtime_context, |
| 463 | + capture_content=self._should_capture_content(), |
| 464 | + ) |
441 | 465 |
|
442 | 466 | span.set_attribute("ioa_start_time", time.time()) # Record start time |
443 | 467 |
|
@@ -517,6 +541,54 @@ def span_processor_on_ending(self, span): |
517 | 541 | ) |
518 | 542 |
|
519 | 543 | determine_reliability_score(span) |
| 544 | + with self._active_llm_spans_lock: |
| 545 | + llm_runtime_context = self._active_llm_spans.pop(span.context.span_id, None) |
| 546 | + if llm_runtime_context is None and is_llm_span(span): |
| 547 | + session_id = span.attributes.get("session.id") |
| 548 | + if session_id: |
| 549 | + self.llm_call_counter.add(1, attributes=span.attributes) |
| 550 | + llm_runtime_context = _llm_runtime_context( |
| 551 | + span, |
| 552 | + session_id=str(session_id), |
| 553 | + agent_name=span.attributes.get(OBSERVE_WORKFLOW_NAME), |
| 554 | + ) |
| 555 | + _emit_llm_started_runtime_event( |
| 556 | + span, |
| 557 | + llm_runtime_context, |
| 558 | + capture_content=self._should_capture_content(), |
| 559 | + ) |
| 560 | + if llm_runtime_context: |
| 561 | + llm_name, llm_session_id, llm_agent_name, llm_operation = ( |
| 562 | + llm_runtime_context |
| 563 | + ) |
| 564 | + capture_content = self._should_capture_content() |
| 565 | + llm_input = ( |
| 566 | + _llm_span_content(span, SpanAttributes.LLM_PROMPTS) |
| 567 | + if capture_content |
| 568 | + else None |
| 569 | + ) |
| 570 | + llm_output = ( |
| 571 | + _llm_span_content(span, SpanAttributes.LLM_COMPLETIONS) |
| 572 | + if capture_content |
| 573 | + else None |
| 574 | + ) |
| 575 | + emit_runtime_event( |
| 576 | + build_runtime_event_attributes( |
| 577 | + RuntimeEventName.LLM_COMPLETED, |
| 578 | + session_id=llm_session_id, |
| 579 | + snapshot_version=next_session_event_version(llm_session_id), |
| 580 | + **{ |
| 581 | + RuntimeEventAttribute.LLM_NAME.value: llm_name, |
| 582 | + RuntimeEventAttribute.LLM_CALL_ID.value: format( |
| 583 | + span.context.span_id, "016x" |
| 584 | + ), |
| 585 | + RuntimeEventAttribute.AGENT_NAME.value: llm_agent_name, |
| 586 | + RuntimeEventAttribute.LLM_INPUT.value: llm_input, |
| 587 | + RuntimeEventAttribute.LLM_OUTPUT.value: llm_output, |
| 588 | + "operation.name": llm_operation, |
| 589 | + }, |
| 590 | + ) |
| 591 | + ) |
520 | 592 | # start_time = span.attributes.get("ioa_start_time") |
521 | 593 |
|
522 | 594 | # Apply transformations if enabled |
@@ -649,6 +721,11 @@ def flush(self): |
649 | 721 | def get_tracer(self): |
650 | 722 | return self.__tracer_provider.get_tracer(TRACER_NAME) |
651 | 723 |
|
| 724 | + def _should_capture_content(self) -> bool: |
| 725 | + return self.enable_content_tracing or bool( |
| 726 | + get_value("override_enable_content_tracing") |
| 727 | + ) |
| 728 | + |
652 | 729 | def record_agent_execution(self, agent_name: str, success: bool): |
653 | 730 | with self._agent_execution_counts_lock: |
654 | 731 | counts = self._agent_execution_counts.setdefault(agent_name, [0, 0]) |
@@ -931,7 +1008,95 @@ def set_external_prompt_tracing_context( |
931 | 1008 |
|
932 | 1009 |
|
933 | 1010 | def is_llm_span(span) -> bool: |
934 | | - return span.attributes.get(SpanAttributes.LLM_REQUEST_TYPE) is not None |
| 1011 | + attributes = span.attributes |
| 1012 | + if attributes.get(SpanAttributes.LLM_REQUEST_TYPE) is not None: |
| 1013 | + return True |
| 1014 | + if attributes.get("gen_ai.operation.name") is not None: |
| 1015 | + return True |
| 1016 | + return attributes.get(SpanAttributes.LLM_SYSTEM) is not None and ( |
| 1017 | + attributes.get(SpanAttributes.LLM_REQUEST_MODEL) is not None |
| 1018 | + or attributes.get(SpanAttributes.LLM_RESPONSE_MODEL) is not None |
| 1019 | + ) |
| 1020 | + |
| 1021 | + |
| 1022 | +def _llm_span_name(span) -> str: |
| 1023 | + return str( |
| 1024 | + span.attributes.get(SpanAttributes.LLM_REQUEST_MODEL) |
| 1025 | + or span.attributes.get(SpanAttributes.LLM_RESPONSE_MODEL) |
| 1026 | + or span.attributes.get(SpanAttributes.LLM_SYSTEM) |
| 1027 | + or span.name |
| 1028 | + ) |
| 1029 | + |
| 1030 | + |
| 1031 | +def _llm_span_operation(span) -> str | None: |
| 1032 | + operation = span.attributes.get( |
| 1033 | + SpanAttributes.LLM_REQUEST_TYPE |
| 1034 | + ) or span.attributes.get("gen_ai.operation.name") |
| 1035 | + if operation is not None: |
| 1036 | + return str(operation) |
| 1037 | + if "." in span.name: |
| 1038 | + return span.name.rsplit(".", 1)[-1] |
| 1039 | + return None |
| 1040 | + |
| 1041 | + |
| 1042 | +def _llm_runtime_context( |
| 1043 | + span, |
| 1044 | + *, |
| 1045 | + session_id: str, |
| 1046 | + agent_name, |
| 1047 | +) -> tuple[str, str, str | None, str | None]: |
| 1048 | + return ( |
| 1049 | + _llm_span_name(span), |
| 1050 | + session_id, |
| 1051 | + str(agent_name) if agent_name is not None else None, |
| 1052 | + _llm_span_operation(span), |
| 1053 | + ) |
| 1054 | + |
| 1055 | + |
| 1056 | +def _emit_llm_started_runtime_event( |
| 1057 | + span, |
| 1058 | + llm_runtime_context: tuple[str, str, str | None, str | None], |
| 1059 | + *, |
| 1060 | + capture_content: bool, |
| 1061 | +) -> None: |
| 1062 | + llm_name, session_id, agent_name, operation = llm_runtime_context |
| 1063 | + llm_input = ( |
| 1064 | + _llm_span_content(span, SpanAttributes.LLM_PROMPTS) if capture_content else None |
| 1065 | + ) |
| 1066 | + emit_runtime_event( |
| 1067 | + build_runtime_event_attributes( |
| 1068 | + RuntimeEventName.LLM_STARTED, |
| 1069 | + session_id=session_id, |
| 1070 | + snapshot_version=next_session_event_version(session_id), |
| 1071 | + **{ |
| 1072 | + RuntimeEventAttribute.LLM_NAME.value: llm_name, |
| 1073 | + RuntimeEventAttribute.LLM_CALL_ID.value: format( |
| 1074 | + span.context.span_id, "016x" |
| 1075 | + ), |
| 1076 | + RuntimeEventAttribute.AGENT_NAME.value: agent_name, |
| 1077 | + RuntimeEventAttribute.LLM_INPUT.value: llm_input, |
| 1078 | + "operation.name": operation, |
| 1079 | + }, |
| 1080 | + ) |
| 1081 | + ) |
| 1082 | + |
| 1083 | + |
| 1084 | +def _llm_span_content(span, prefix: str) -> str | None: |
| 1085 | + pattern = re.compile(rf"^{re.escape(prefix)}\.(\d+)(?:\.(.+))?$") |
| 1086 | + messages: dict[int, dict[str, object]] = {} |
| 1087 | + for key, value in span.attributes.items(): |
| 1088 | + match = pattern.match(key) |
| 1089 | + if match is None: |
| 1090 | + continue |
| 1091 | + index = int(match.group(1)) |
| 1092 | + field = match.group(2) or "content" |
| 1093 | + messages.setdefault(index, {})[field] = value |
| 1094 | + if not messages: |
| 1095 | + return None |
| 1096 | + ordered_messages = [ |
| 1097 | + dict(sorted(messages[index].items())) for index in sorted(messages) |
| 1098 | + ] |
| 1099 | + return json.dumps(ordered_messages, separators=(",", ":"), default=str) |
935 | 1100 |
|
936 | 1101 |
|
937 | 1102 | def init_spans_exporter(api_endpoint: str, headers: Dict[str, str]) -> SpanExporter: |
|
0 commit comments