Skip to content
Draft
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
4 changes: 4 additions & 0 deletions openhands-agent-server/openhands/agent_server/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,10 @@ def _sanitize_validation_errors(errors: Sequence[Any]) -> list[dict]:
error = dict(error) # shallow copy so we don't mutate the original
if "input" in error:
error["input"] = sanitize_dict(error["input"])
if isinstance(error.get("ctx"), dict) and isinstance(
error["ctx"].get("error"), Exception
):
error["ctx"] = {**error["ctx"], "error": str(error["ctx"]["error"])}
sanitized.append(error)
return sanitized

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -676,6 +676,8 @@ def _token_streaming_callback(chunk: LLMStreamChunk) -> None:
hook_config=self.stored.hook_config,
tags=self.stored.tags,
user_id=self.stored.user_id,
observability_metadata=self.stored.observability_metadata,
observability_tags=self.stored.observability_tags,
)

conversation.set_confirmation_policy(self.stored.confirmation_policy)
Expand Down
15 changes: 13 additions & 2 deletions openhands-sdk/openhands/sdk/conversation/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
ConversationCallbackType,
ConversationID,
ConversationTokenCallbackType,
TraceMetadataValue,
)
from openhands.sdk.llm.llm import LLM
from openhands.sdk.llm.message import Message
Expand Down Expand Up @@ -128,21 +129,31 @@ def __init__(self) -> None:
self._observability_root_span: RootSpan | None = None

def _start_observability_span(
self, session_id: str, user_id: str | None = None
self,
session_id: str,
user_id: str | None = None,
metadata: dict[str, TraceMetadataValue] | None = None,
tags: list[str] | None = None,
) -> None:
"""Start a per-conversation observability root span.

Args:
session_id: The session ID to associate with the trace
user_id: Optional user ID to associate with the trace
metadata: Optional trace-level metadata to attach to observability backends
tags: Optional span tags to attach to the conversation root span
"""
if not should_enable_observability():
return
if self._observability_root_span is not None:
# Idempotent: never start two roots for one conversation.
return
self._observability_root_span = start_root_span(
"conversation", session_id=session_id, user_id=user_id
"conversation",
session_id=session_id,
user_id=user_id,
metadata=metadata,
tags=tags,
)

def _end_observability_span(self) -> None:
Expand Down
11 changes: 11 additions & 0 deletions openhands-sdk/openhands/sdk/conversation/conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
ConversationID,
ConversationTokenCallbackType,
StuckDetectionThresholds,
TraceMetadataValue,
)
from openhands.sdk.conversation.visualizer import (
ConversationVisualizerBase,
Expand Down Expand Up @@ -81,6 +82,8 @@ def __new__(
delete_on_close: bool = True,
tags: dict[str, str] | None = None,
user_id: str | None = None,
observability_metadata: dict[str, TraceMetadataValue] | None = None,
observability_tags: list[str] | None = None,
) -> "LocalConversation": ...

@overload
Expand All @@ -106,6 +109,8 @@ def __new__(
delete_on_close: bool = True,
tags: dict[str, str] | None = None,
user_id: str | None = None,
observability_metadata: dict[str, TraceMetadataValue] | None = None,
observability_tags: list[str] | None = None,
) -> "RemoteConversation": ...

def __new__(
Expand All @@ -131,6 +136,8 @@ def __new__(
delete_on_close: bool = True,
tags: dict[str, str] | None = None,
user_id: str | None = None,
observability_metadata: dict[str, TraceMetadataValue] | None = None,
observability_tags: list[str] | None = None,
) -> BaseConversation:
from openhands.sdk.conversation.impl.local_conversation import LocalConversation
from openhands.sdk.conversation.impl.remote_conversation import (
Expand Down Expand Up @@ -185,6 +192,8 @@ def __new__(
delete_on_close=delete_on_close,
tags=effective_tags if effective_tags else None,
user_id=user_id,
observability_metadata=observability_metadata,
observability_tags=observability_tags,
)

return LocalConversation(
Expand All @@ -204,4 +213,6 @@ def __new__(
delete_on_close=delete_on_close,
tags=tags,
user_id=user_id,
observability_metadata=observability_metadata,
observability_tags=observability_tags,
)
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
ConversationID,
ConversationTokenCallbackType,
StuckDetectionThresholds,
TraceMetadataValue,
)
from openhands.sdk.conversation.visualizer import (
ConversationVisualizerBase,
Expand Down Expand Up @@ -116,6 +117,8 @@ def __init__(
cipher: Cipher | None = None,
tags: dict[str, str] | None = None,
user_id: str | None = None,
observability_metadata: dict[str, TraceMetadataValue] | None = None,
observability_tags: list[str] | None = None,
**_: object,
):
"""Initialize the conversation.
Expand Down Expand Up @@ -157,6 +160,9 @@ def __init__(
(lost) on serialization.
tags: Optional key-value tags for the conversation. Keys must be
lowercase alphanumeric, values up to 256 characters.
user_id: Optional user ID to associate with observability traces.
observability_metadata: Optional trace metadata for observability backends.
observability_tags: Optional root span tags for observability backends.
"""
super().__init__() # Initialize with span tracking
# Mark cleanup as initiated as early as possible to avoid races or partially
Expand Down Expand Up @@ -284,7 +290,12 @@ def _default_callback(e):
self.update_secrets(secret_values)

atexit.register(self.close)
self._start_observability_span(str(desired_id), user_id=user_id)
self._start_observability_span(
str(desired_id),
user_id=user_id,
metadata=observability_metadata,
tags=observability_tags,
)
self.delete_on_close = delete_on_close

@property
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
ConversationCallbackType,
ConversationID,
StuckDetectionThresholds,
TraceMetadataValue,
)
from openhands.sdk.conversation.visualizer import (
ConversationVisualizerBase,
Expand Down Expand Up @@ -666,6 +667,8 @@ def __init__(
delete_on_close: bool = False,
tags: dict[str, str] | None = None,
user_id: str | None = None,
observability_metadata: dict[str, TraceMetadataValue] | None = None,
observability_tags: list[str] | None = None,
**_: object,
) -> None:
"""Remote conversation proxy that talks to an agent server.
Expand Down Expand Up @@ -695,6 +698,8 @@ def __init__(
tags: Optional key-value tags for the conversation. Keys must be
lowercase alphanumeric, values up to 256 characters.
user_id: Optional user ID to associate with observability traces
observability_metadata: Optional trace metadata for observability backends.
observability_tags: Optional root span tags for observability backends.
"""
super().__init__() # Initialize base class with span tracking
self.agent = agent
Expand Down Expand Up @@ -759,8 +764,10 @@ def __init__(
"plugins": [p.model_dump() for p in plugins] if plugins else None,
# Include hook_config for server-side hooks
"hook_config": hook_config.model_dump() if hook_config else None,
# Include tags if provided
# Include tags and observability metadata if provided
"tags": tags or {},
"observability_metadata": observability_metadata or {},
Comment thread
neubig marked this conversation as resolved.
"observability_tags": observability_tags or [],
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Important: The server-side LocalConversation now consumes these observability fields from StoredConversation, but the remote create payload still omits the existing user_id field. That means a remote SDK caller gets user_id on the client proxy root span only, while the server root span covering agent execution loses it. Please include "user_id": user_id in this payload and add a payload propagation assertion alongside the metadata/tags coverage.

}
if stuck_detection_thresholds is not None:
# Convert to StuckDetectionThresholds if dict, then serialize
Expand Down Expand Up @@ -884,7 +891,12 @@ def run_complete_callback(event: Event) -> None:
secret_values: dict[str, SecretValue] = {k: v for k, v in secrets.items()}
self.update_secrets(secret_values)

self._start_observability_span(str(self._id), user_id=user_id)
self._start_observability_span(
str(self._id),
user_id=user_id,
metadata=observability_metadata,
tags=observability_tags,
)
# All hooks (including SessionStart/SessionEnd) are executed server-side.
# hook_config is sent in the creation payload.
self.delete_on_close = delete_on_close
Expand Down
17 changes: 16 additions & 1 deletion openhands-sdk/openhands/sdk/conversation/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@
from openhands.sdk.agent.acp_agent import ACPAgent as ACPAgent
from openhands.sdk.agent.agent import Agent as Agent
from openhands.sdk.agent.base import AgentBase
from openhands.sdk.conversation.types import ConversationTags
from openhands.sdk.conversation.types import (
ConversationObservabilityMetadata,
ConversationObservabilityTags,
ConversationTags,
)
from openhands.sdk.hooks import HookConfig
from openhands.sdk.llm.message import ImageContent, Message, TextContent
from openhands.sdk.plugin import PluginSource
Expand Down Expand Up @@ -179,6 +183,17 @@ class StartConversationRequest(BaseModel):
"traces can be queried by user."
),
)
observability_metadata: ConversationObservabilityMetadata = Field(
default_factory=dict,
description=(
"Trace-level metadata to attach to observability backends. Values must "
"be scalars or homogeneous scalar lists supported by OpenTelemetry."
),
)
observability_tags: ConversationObservabilityTags = Field(
default_factory=list,
description="Tags to attach to the conversation root observability span.",
)
autotitle: bool = Field(
default=True,
description=(
Expand Down
69 changes: 67 additions & 2 deletions openhands-sdk/openhands/sdk/conversation/types.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import re
import uuid
from collections.abc import Callable
from typing import Annotated
from collections.abc import Callable, Sequence
from typing import Annotated, Any

from pydantic import BaseModel, BeforeValidator, Field

Expand Down Expand Up @@ -44,6 +44,71 @@ def _validate_tags(v: dict[str, str] | None) -> dict[str, str]:
Keys must be lowercase alphanumeric. Values are arbitrary strings up to 256 chars.
"""

type TraceMetadataValue = (
str
| bool
| int
| float
| Sequence[str]
| Sequence[bool]
| Sequence[int]
| Sequence[float]
)


def _validate_observability_metadata(
v: Any,
) -> dict[str, TraceMetadataValue]:
if v is None:
return {}
if not isinstance(v, dict):
raise ValueError("Observability metadata must be a dictionary")
for key, value in v.items():
Comment thread
neubig marked this conversation as resolved.
if not isinstance(key, str) or not key:
raise ValueError("Observability metadata keys must be non-empty strings")
if isinstance(value, str | bool | int | float):
continue
if isinstance(value, Sequence) and not isinstance(value, bytes | bytearray):
if all(isinstance(item, str) for item in value):
continue
if all(isinstance(item, bool) for item in value):
continue
if all(
isinstance(item, int) and not isinstance(item, bool) for item in value
):
continue
if all(isinstance(item, float) for item in value):
continue
raise ValueError(
Comment thread
neubig marked this conversation as resolved.
f"Observability metadata value for '{key}' must be a scalar "
"or a sequence of strings, booleans, integers, or floats"
)
return v


ConversationObservabilityMetadata = Annotated[
dict[str, TraceMetadataValue],
BeforeValidator(_validate_observability_metadata),
]
"""Validated dict of Laminar/OTel trace metadata for a conversation."""


def _validate_observability_tags(v: Any) -> list[str]:
if v is None:
return []
if not isinstance(v, list):
raise ValueError("Observability tags must be a list")
if not all(isinstance(tag, str) for tag in v):
raise ValueError("Observability tags must contain only strings")
return v


ConversationObservabilityTags = Annotated[
list[str],
BeforeValidator(_validate_observability_tags),
]
"""Validated list of Laminar/OTel span tags for a conversation."""


class StuckDetectionThresholds(BaseModel):
"""Configuration for stuck detection thresholds.
Expand Down
22 changes: 18 additions & 4 deletions openhands-sdk/openhands/sdk/observability/laminar.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,21 +253,27 @@ def __init__(
name: str,
session_id: str | None = None,
user_id: str | None = None,
metadata: dict[str, Any] | None = None,
tags: list[str] | None = None,
) -> None:
from lmnr import Laminar

# ``start_span`` returns a span without attaching it as the current
# OTel context; we'll restore it on every entry point via ``use_span``.
self.span = Laminar.start_span(name)
if session_id or user_id:
# ``set_trace_session_id`` / ``set_trace_user_id`` require an
# active span; briefly enter the span context to apply them.
if session_id or user_id or metadata or tags:
# These trace/span helpers require an active span; briefly enter
# the span context to apply conversation-level observability data.
with contextlib.suppress(Exception):
with Laminar.use_span(self.span):
if session_id:
Laminar.set_trace_session_id(session_id)
if user_id:
Laminar.set_trace_user_id(user_id)
if metadata:
Laminar.set_trace_metadata(metadata)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Important: These new metadata/tag setters run inside Laminar.use_span() with its default record_exception=True / set_status_on_exception=True. If a best-effort setter raises (for example due to Laminar API or serialization incompatibility), contextlib.suppress hides the failure from the SDK caller but Laminar still records the exception and marks the conversation root span as errored. Please use Laminar.use_span(self.span, record_exception=False, set_status_on_exception=False) for this setup block so observability setup failures don't corrupt trace status.

if tags:
Laminar.set_span_tags(tags)
self._ended = False

def end(self) -> None:
Expand All @@ -285,6 +291,8 @@ def start_root_span(
name: str,
session_id: str | None = None,
user_id: str | None = None,
metadata: dict[str, Any] | None = None,
tags: list[str] | None = None,
) -> RootSpan | None:
"""Create a long-lived root span for an owning object.

Expand All @@ -293,7 +301,13 @@ def start_root_span(
if not should_enable_observability():
return None
try:
return RootSpan(name, session_id=session_id, user_id=user_id)
return RootSpan(
name,
session_id=session_id,
user_id=user_id,
metadata=metadata,
tags=tags,
)
except Exception:
logger.debug("Failed to create observability root span", exc_info=True)
return None
Expand Down
Loading
Loading