Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,32 @@ All notable changes to `xgen-agent-runtime` are recorded here. The format
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
this project adheres to [Semantic Versioning](https://semver.org/).

## [4.91.0] - 2026-10-15

### Added: LLM 호출·도구 실행 OpenTelemetry span (`xgen_agent_runtime.observability`)

dev Tempo 의 Geny 턴 trace(2026-10-15)에는 `chat` span 이 하나도 없었다. LLM 의 `POST /v1/chat/completions`
다섯 건과 도구가 부른 HTTP·MCP 호출은 에이전트 턴이 아니라 워크플로우 span 바로 아래에 흩어져, 어느 호출이 병목인지
trace 에서 읽을 수 없었다. 호스트는 턴 끝의 사용량 합계와 도구 사건만 받으므로 호출 구간도 부모 관계도 되살릴 수 없다.
그래서 호출과 실행이 일어나는 런타임이 span 을 연다.

- **`chat {model}`**: Stage 6 의 호출 1건(재시도 포함)마다. 현재 span 으로 열어 벤더 SDK 의 HTTP 자동 계측 span 이 그
아래에 달린다. 속성은 GenAI semantic conventions — `gen_ai.provider.name`·`gen_ai.request.model`·
`gen_ai.response.model`·`gen_ai.response.finish_reasons`·`gen_ai.usage.*`(캐시 토큰은 0 이면 생략), 그리고
`xgen.llm.stream`·`message_count`·`has_tools`·`output_length`·`tool_calls`. `APIError` 는 오류 코드(없으면 분류)를
`error.type` 으로 남긴다.
- **`execute_tool {tool}`**: `RegistryRouter.route` 에서 도구 실행 1건마다. 모든 실행기(순차·병렬·분할·스트리밍)와
CLI 도구 표면이 여기를 지나므로 한 곳에서 연다. 도구가 부르는 HTTP·MCP 호출이 이 span 아래에 달린다.
`xgen.tool.is_error`·`xgen.tool.result_size` 를 남기고, 실패면 `error.type=tool_error`.
- 프롬프트·응답·도구 입출력 **본문은 남기지 않는다**(길이·개수만). 취소는 오류로 표시하지 않는다.
- span 의 부모는 호출 시점의 현재 context 다. 턴을 도는 전용 이벤트 루프의 Task 가 호출 스레드의 context 를
이어받으므로, 호스트가 턴 span 을 현재 context 로 두고 `run_turn`/`stream_turn` 을 부르면 그 아래에 달린다.
- **선택 의존성**: `opentelemetry-api` 가 없거나 `XGEN_AGENT_RUNTIME_OTEL=0` 이면 전 경로가 no-op 이다. 호스트는
`observability.spans_enabled()` 가 참이면 자기 쪽 같은 span(사건으로 만든 도구 span)과 턴 span 의 `gen_ai.usage.*` 를
끈다 — 둘 다 내면 같은 호출이 두 번 나오고 토큰이 두 번 더해진다. span 조작 실패는 실행을 깨지 않는다.
- 구성: `observability.spans`(span 수명 — 켜짐 판정·tracer·현재 span 으로 열고 닫기), `observability.attributes`
(속성 키 — xgen-workflow 와 같은 이름), `observability.genai`(`chat`/`execute_tool` span 과 속성 기록).

## [4.90.0] - 2026-10-13

### Added: CLI 구독 한도 신호와 한도 소진 분류
Expand Down
6 changes: 5 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "xgen-agent-runtime"
version = "4.90.0"
version = "4.91.0"
description = "Harness-engineered agent pipeline library with 21-stage dual-abstraction architecture, built on the Anthropic API"
readme = "README.md"
license = "Apache-2.0"
Expand Down Expand Up @@ -115,6 +115,10 @@ dev = [
"ruff>=0.4.0",
"mypy>=1.10",
"asyncssh>=2.14",
# 테스트 전용: observability(LLM 호출·도구 실행 span) 테스트가 span 을 모으는 데 쓴다.
# 런타임은 opentelemetry 를 의존성으로 선언하지 않는다 — 없으면 span 을 내지 않을 뿐이다.
# 이게 없으면 tests/test_otel_spans.py 가 통째로 skip 되어 CI 가 이 경로를 검증하지 못한다.
"opentelemetry-sdk>=1.30",
]

[project.urls]
Expand Down
48 changes: 48 additions & 0 deletions src/xgen_agent_runtime/observability/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""OpenTelemetry 계측 — 런타임이 **직접 하는 일**(LLM 호출 1건, 도구 실행 1건)을 span 으로 남긴다.

왜 런타임이 내는가
------------------
호스트(xgen-workflow)는 턴이 끝난 뒤 받는 사용량 합계와 도구 시작/끝 사건으로만 span 을
만들 수 있었다. 그 방식으로는 두 가지가 끝내 안 된다.

* **LLM 호출 1건의 구간.** 사건은 큐를 지나 늦게 닿아 실제 호출 구간이 아니다. 그래서 trace 에
``chat`` span 이 없고 ``POST /v1/chat/completions`` 같은 HTTP span 만 남아 병목을 읽을 수 없었다.
* **부모 관계.** 사건으로 연 span 은 현재 context 가 아니라서, 도구가 부르는 HTTP·MCP 호출이
그 도구 아래로 가지 못하고 턴 밖에 흩어졌다.

호출과 실행이 일어나는 자리가 여기이므로 span 도 여기서 연다. span 은 현재 context 의 자식이 되고,
그 안의 HTTP 자동 계측 span 은 이 span 의 자식이 된다.

구성
----
* ``spans`` — span 수명(켜짐 판정·tracer·현재 span 으로 열고 닫기). GenAI 를 모른다.
* ``attributes`` — 속성 키. OpenTelemetry GenAI semantic conventions + ``xgen.*`` 확장.
* ``genai`` — ``chat {model}`` / ``execute_tool {tool}`` span 과 응답·결과의 속성 기록.

규약
----
* 토큰(``gen_ai.usage.*``)은 **호출 span 에만** 붙인다. 호스트는 :func:`spans_enabled` 가 참이면
턴 합계를 ``gen_ai.usage.*`` 로 다시 쓰지 않는다 — 더할 때 두 번 세지 않게.
* 프롬프트·응답·도구 입출력 **본문은 남기지 않는다.** 길이·개수만.
* ``opentelemetry-api`` 는 선택 의존성이다. 없거나 ``XGEN_AGENT_RUNTIME_OTEL=0`` 이면 전 경로가
no-op 이다. 호스트가 SDK 를 설정하지 않았으면 API 가 no-op span 을 준다.
* 관측이 실행을 깨선 안 된다 — span 조작 실패는 삼킨다.
"""

from xgen_agent_runtime.observability.genai import (
llm_call_span,
record_llm_response,
record_tool_result,
tool_span,
)
from xgen_agent_runtime.observability.spans import ENV_SWITCH, SpanHandle, spans_enabled

__all__ = [
"ENV_SWITCH",
"SpanHandle",
"llm_call_span",
"record_llm_response",
"record_tool_result",
"spans_enabled",
"tool_span",
]
35 changes: 35 additions & 0 deletions src/xgen_agent_runtime/observability/attributes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""span 속성 키 — OpenTelemetry GenAI semantic conventions + ``xgen.*`` 확장.

호스트(xgen-workflow ``service/observability/attributes.py``)와 **같은 키**를 쓴다. 대시보드·알림이
키 이름으로 읽으므로, 여기서 이름을 바꾸면 호스트 쪽 조회가 조용히 비게 된다.
"""

from __future__ import annotations

# ── GenAI semantic conventions ─────────────────────────────────────────────
GEN_AI_OPERATION_NAME = "gen_ai.operation.name"
GEN_AI_PROVIDER_NAME = "gen_ai.provider.name"
GEN_AI_REQUEST_MODEL = "gen_ai.request.model"
GEN_AI_RESPONSE_MODEL = "gen_ai.response.model"
GEN_AI_RESPONSE_ID = "gen_ai.response.id"
GEN_AI_RESPONSE_FINISH_REASONS = "gen_ai.response.finish_reasons"
GEN_AI_USAGE_INPUT_TOKENS = "gen_ai.usage.input_tokens"
GEN_AI_USAGE_OUTPUT_TOKENS = "gen_ai.usage.output_tokens"
GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS = "gen_ai.usage.cache_read.input_tokens"
GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS = "gen_ai.usage.cache_creation.input_tokens"
GEN_AI_TOOL_NAME = "gen_ai.tool.name"
GEN_AI_TOOL_TYPE = "gen_ai.tool.type"
ERROR_TYPE = "error.type"

OP_CHAT = "chat"
OP_EXECUTE_TOOL = "execute_tool"
TOOL_TYPE_FUNCTION = "function"

# ── xgen 확장 ──────────────────────────────────────────────────────────────
XGEN_LLM_STREAM = "xgen.llm.stream"
XGEN_LLM_MESSAGE_COUNT = "xgen.llm.message_count"
XGEN_LLM_HAS_TOOLS = "xgen.llm.has_tools"
XGEN_LLM_OUTPUT_LENGTH = "xgen.llm.output_length"
XGEN_LLM_TOOL_CALLS = "xgen.llm.tool_calls"
XGEN_TOOL_IS_ERROR = "xgen.tool.is_error"
XGEN_TOOL_RESULT_SIZE = "xgen.tool.result_size"
125 changes: 125 additions & 0 deletions src/xgen_agent_runtime/observability/genai.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
"""GenAI span — LLM 호출 1건(``chat {model}``)과 도구 실행 1건(``execute_tool {tool}``).

이름 ``{...}`` 자리에는 저카디널리티 값(모델·도구 이름)만 들어간다 — 규약이 허용하는 예외이고,
trace UI 의 operation 목록에서 바로 읽힌다. span 수명은 ``spans`` 에 맡기고, 여기서는 무엇을
어떤 키로 남길지만 정한다.
"""

from __future__ import annotations

from contextlib import contextmanager
from typing import Any, Dict, Iterator, Optional

from xgen_agent_runtime.observability import attributes as attrs
from xgen_agent_runtime.observability.spans import (
SpanHandle,
client_kind,
current_span,
internal_kind,
)


def _span_name(operation: str, target: str) -> str:
target = (target or "").strip()
return f"{operation} {target}" if target else operation


# ── LLM 호출 ────────────────────────────────────────────────────────────────


@contextmanager
def llm_call_span(
*,
model: str,
provider: str = "",
stream: bool = False,
message_count: Optional[int] = None,
has_tools: Optional[bool] = None,
) -> Iterator[SpanHandle]:
"""LLM 호출 1건(재시도 포함). 응답은 :func:`record_llm_response` 로 남긴다."""
attributes: Dict[str, Any] = {
attrs.GEN_AI_OPERATION_NAME: attrs.OP_CHAT,
attrs.GEN_AI_PROVIDER_NAME: provider,
attrs.GEN_AI_REQUEST_MODEL: model,
attrs.XGEN_LLM_STREAM: bool(stream),
attrs.XGEN_LLM_MESSAGE_COUNT: message_count,
attrs.XGEN_LLM_HAS_TOOLS: has_tools,
}
with current_span(_span_name(attrs.OP_CHAT, model), client_kind(), attributes) as span:
yield span


def _usage_attributes(usage: Any) -> Dict[str, Any]:
"""호출 1건의 토큰. 캐시 토큰은 0 이면 생략한다 — 캐시를 안 쓰는 모델이 대부분이라 잡음이다."""
payload: Dict[str, Any] = {
attrs.GEN_AI_USAGE_INPUT_TOKENS: int(getattr(usage, "input_tokens", 0) or 0),
attrs.GEN_AI_USAGE_OUTPUT_TOKENS: int(getattr(usage, "output_tokens", 0) or 0),
}
cache_read = int(getattr(usage, "cache_read_input_tokens", 0) or 0)
cache_write = int(getattr(usage, "cache_creation_input_tokens", 0) or 0)
if cache_read:
payload[attrs.GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS] = cache_read
if cache_write:
payload[attrs.GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS] = cache_write
return payload


def record_llm_response(span: SpanHandle, response: Any) -> None:
"""응답의 모델·종료 사유·토큰·크기를 호출 span 에 남긴다. 본문은 남기지 않는다."""
if not span.recording or response is None:
return
payload: Dict[str, Any] = {
attrs.GEN_AI_RESPONSE_MODEL: getattr(response, "model", None),
attrs.GEN_AI_RESPONSE_ID: getattr(response, "message_id", None),
attrs.XGEN_LLM_OUTPUT_LENGTH: len(getattr(response, "text", "") or ""),
attrs.XGEN_LLM_TOOL_CALLS: len(getattr(response, "tool_calls", None) or []),
}
stop_reason = getattr(response, "stop_reason", "")
if stop_reason:
payload[attrs.GEN_AI_RESPONSE_FINISH_REASONS] = [str(stop_reason)]
usage = getattr(response, "usage", None)
if usage is not None:
payload.update(_usage_attributes(usage))
span.set(payload)


# ── 도구 실행 ───────────────────────────────────────────────────────────────


@contextmanager
def tool_span(*, tool_name: str) -> Iterator[SpanHandle]:
"""도구 실행 1건. 결과는 :func:`record_tool_result` 로 남긴다."""
attributes: Dict[str, Any] = {
attrs.GEN_AI_OPERATION_NAME: attrs.OP_EXECUTE_TOOL,
attrs.GEN_AI_TOOL_NAME: tool_name,
attrs.GEN_AI_TOOL_TYPE: attrs.TOOL_TYPE_FUNCTION,
}
with current_span(
_span_name(attrs.OP_EXECUTE_TOOL, tool_name), internal_kind(), attributes
) as span:
yield span


def _result_size(result: Any) -> Optional[int]:
content = getattr(result, "display_text", None) or getattr(result, "content", None)
if isinstance(content, (str, bytes, list, dict)):
return len(content)
return None


def record_tool_result(span: SpanHandle, result: Any) -> None:
"""결과의 성패와 크기만 남긴다. 본문은 남기지 않는다."""
if not span.recording or result is None:
return
is_error = bool(getattr(result, "is_error", False))
span.set(
{
attrs.XGEN_TOOL_IS_ERROR: is_error,
attrs.XGEN_TOOL_RESULT_SIZE: _result_size(result),
}
)
if is_error:
span.fail("tool_error")


__all__ = ["llm_call_span", "record_llm_response", "record_tool_result", "tool_span"]
Loading
Loading