Skip to content

Add conversation attribution to Laminar traces#3580

Open
neubig wants to merge 1 commit into
mainfrom
neubig/automation-laminar-attribution
Open

Add conversation attribution to Laminar traces#3580
neubig wants to merge 1 commit into
mainfrom
neubig/automation-laminar-attribution

Conversation

@neubig

@neubig neubig commented Jun 9, 2026

Copy link
Copy Markdown
Member

HUMAN:
Adds attribution plumbing so automation conversations can be tied back to their owning user in Laminar and filtered by conversation tags.

  • A human has tested these changes.

AGENT:


Why

Automation conversations currently carry automation context in product metadata, but their Laminar traces do not reliably receive the owning user ID or automation tags. This makes abuse triage require joins outside Laminar.

Summary

  • Send user_id in RemoteConversation create requests so the agent server can call Laminar.set_trace_user_id().
  • Attach conversation tags to the root span as conversation.tags.<key> attributes.
  • Pass tags into local and remote root span creation, with regression coverage.

Issue

Closes #3587

This PR description update was created by an AI agent (OpenHands) on behalf of Graham Neubig.

How to Test

  • git diff --check
  • CI: sdk-tests, agent-server-tests, cross-tests, pre-commit, and API compatibility checks are passing on this PR.
  • Local full pytest was not run because this environment has Python 3.10 only, while this repo requires newer Python syntax/tooling.

Video/Screenshots

N/A: observability plumbing change with regression tests.

Type

  • Bug fix
  • Feature
  • Refactor
  • Breaking change
  • Docs / chore

Notes

This pairs with OpenHands/automation#169, which passes the automation owner user ID into SDK conversation creation.


Agent Server images for this PR

GHCR package: https://github.com/OpenHands/agent-sdk/pkgs/container/agent-server

Variants & Base Images

Variant Architectures Base Image Docs / Tags
java amd64, arm64 eclipse-temurin:17-jdk Link
python amd64, arm64 nikolaik/python-nodejs:python3.13-nodejs22-slim Link
golang amd64, arm64 golang:1.21-bookworm Link

Pull (multi-arch manifest)

# Each variant is a multi-arch manifest supporting both amd64 and arm64
docker pull ghcr.io/openhands/agent-server:6c2e02b-python

Run

docker run -it --rm \
  -p 8000:8000 \
  --name agent-server-6c2e02b-python \
  ghcr.io/openhands/agent-server:6c2e02b-python

All tags pushed for this build

ghcr.io/openhands/agent-server:6c2e02b-golang-amd64
ghcr.io/openhands/agent-server:6c2e02b017aba72dbbfdcdce79595392eb55c994-golang-amd64
ghcr.io/openhands/agent-server:neubig-automation-laminar-attribution-golang-amd64
ghcr.io/openhands/agent-server:6c2e02b-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:6c2e02b-golang-arm64
ghcr.io/openhands/agent-server:6c2e02b017aba72dbbfdcdce79595392eb55c994-golang-arm64
ghcr.io/openhands/agent-server:neubig-automation-laminar-attribution-golang-arm64
ghcr.io/openhands/agent-server:6c2e02b-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:6c2e02b-java-amd64
ghcr.io/openhands/agent-server:6c2e02b017aba72dbbfdcdce79595392eb55c994-java-amd64
ghcr.io/openhands/agent-server:neubig-automation-laminar-attribution-java-amd64
ghcr.io/openhands/agent-server:6c2e02b-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:6c2e02b-java-arm64
ghcr.io/openhands/agent-server:6c2e02b017aba72dbbfdcdce79595392eb55c994-java-arm64
ghcr.io/openhands/agent-server:neubig-automation-laminar-attribution-java-arm64
ghcr.io/openhands/agent-server:6c2e02b-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:6c2e02b-python-amd64
ghcr.io/openhands/agent-server:6c2e02b017aba72dbbfdcdce79595392eb55c994-python-amd64
ghcr.io/openhands/agent-server:neubig-automation-laminar-attribution-python-amd64
ghcr.io/openhands/agent-server:6c2e02b-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-amd64
ghcr.io/openhands/agent-server:6c2e02b-python-arm64
ghcr.io/openhands/agent-server:6c2e02b017aba72dbbfdcdce79595392eb55c994-python-arm64
ghcr.io/openhands/agent-server:neubig-automation-laminar-attribution-python-arm64
ghcr.io/openhands/agent-server:6c2e02b-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-arm64
ghcr.io/openhands/agent-server:6c2e02b-golang
ghcr.io/openhands/agent-server:6c2e02b017aba72dbbfdcdce79595392eb55c994-golang
ghcr.io/openhands/agent-server:neubig-automation-laminar-attribution-golang
ghcr.io/openhands/agent-server:6c2e02b-golang_tag_1.21-bookworm
ghcr.io/openhands/agent-server:6c2e02b-java
ghcr.io/openhands/agent-server:6c2e02b017aba72dbbfdcdce79595392eb55c994-java
ghcr.io/openhands/agent-server:neubig-automation-laminar-attribution-java
ghcr.io/openhands/agent-server:6c2e02b-eclipse-temurin_tag_17-jdk
ghcr.io/openhands/agent-server:6c2e02b-python
ghcr.io/openhands/agent-server:6c2e02b017aba72dbbfdcdce79595392eb55c994-python
ghcr.io/openhands/agent-server:neubig-automation-laminar-attribution-python
ghcr.io/openhands/agent-server:6c2e02b-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim

About Multi-Architecture Support

  • Each variant tag (e.g., 6c2e02b-python) is a multi-arch manifest supporting both amd64 and arm64
  • Docker automatically pulls the correct architecture for your platform
  • Individual architecture tags (e.g., 6c2e02b-python-amd64) are also available if needed

@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Python API breakage checks — ✅ PASSED

Result:PASSED

Behavioral default changes detected

These public Field(default=...) changes differ from the latest released baseline, but they were already present on the base branch, so this PR was not auto-marked with the release-note-required label:

  • openhands.sdk.settings.model.OpenHandsAgentSettings.condenser: CondenserSettingsLLMSummarizingCondenserSettings

Action log

@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

REST API breakage checks (OpenAPI) — ✅ PASSED

Result:PASSED

Action log

@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Coverage

Coverage Report •
FileStmtsMissCoverMissing
openhands-sdk/openhands/sdk/conversation
   base.py102298%219, 273
openhands-sdk/openhands/sdk/conversation/impl
   local_conversation.py6955592%90, 365, 370, 515, 561, 630, 646, 722, 967–968, 1045–1046, 1049, 1169, 1172–1173, 1197, 1230–1231, 1234, 1240, 1321, 1324, 1328–1329, 1333–1334, 1337, 1344, 1369, 1373, 1376, 1395, 1447, 1450, 1489, 1497, 1501–1503, 1510, 1622, 1627, 1737, 1739, 1743–1744, 1755–1756, 1781, 1976, 1980, 2050, 2057–2058
   remote_conversation.py6918188%143, 170, 183, 185–188, 198, 207, 220–221, 226–229, 313, 323–325, 331, 409, 556–559, 561, 587–591, 596–599, 602, 618, 775–776, 780–781, 795, 841, 854–855, 879–880, 903–906, 908–909, 935, 945, 949, 958–959, 998, 1148–1149, 1243–1244, 1248, 1253–1257, 1263–1269, 1282, 1287, 1329, 1542–1543
openhands-sdk/openhands/sdk/observability
   laminar.py1984875%37–41, 103, 170–171, 184–185, 211–212, 279–286, 301–302, 305–307, 326–327, 330–332, 336, 338–340, 355, 359, 411, 425, 469, 491–494, 527–529, 531–532
TOTAL29688843071% 

@all-hands-bot all-hands-bot left a comment

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.

⚠️ QA Report: PASS WITH ISSUES

Functional QA verified the Laminar attribution changes work for SDK local and remote conversation creation; CI is not fully green yet.

Does this PR achieve its stated goal?

Yes. The PR set out to add conversation attribution to Laminar traces by forwarding user_id for remote conversations and attaching conversation tags as root-span attributes. Exercising the SDK before/after showed local conversation tags now reach the Laminar span attributes, and remote conversation creation now sends user_id alongside tags in the /api/conversations payload.

Phase Result
Environment Setup make build completed successfully with uv 0.11.19 and Python 3.13.13
CI Status ⚠️ 22 successful, 1 failing (PR Description Check), 1 skipped, 7 pending at review time
Functional Verification ✅ Local Laminar span attributes and remote create payload verified before/after
Functional Verification

Test 1: Local SDK conversations attach automation tags to the Laminar root span

Step 1 — Establish baseline on origin/main (without the fix):
Ran git switch --detach origin/main && uv run python - <<'PY' ... PY, creating a real Conversation(...) with tags={"automationid": "auto-1", "automationrunid": "run-1"} while intercepting Laminar.start_span with an in-process fake span to observe SDK behavior without an external Laminar account.

Relevant output:

local_span_attributes= {}

This shows the old SDK accepted conversation tags but did not attach them to the Laminar root span.

Step 2 — Apply the PR's changes:
Checked out neubig/automation-laminar-attribution at 6c2e02b017aba72dbbfdcdce79595392eb55c994.

Step 3 — Re-run with the fix in place:
Ran the same SDK conversation creation path on the PR branch.

Relevant output:

local_span_attributes= {'conversation.tags.automationid': 'auto-1', 'conversation.tags.automationrunid': 'run-1'}

This confirms local conversation tags are now converted to conversation.tags.<key> span attributes, matching the PR goal.

Test 2: Remote SDK conversations forward user_id and tags in the create payload

Step 1 — Establish baseline on origin/main (without the fix):
Ran git switch --detach origin/main && uv run python - <<'PY' ... PY, creating a real RemoteConversation through the public Conversation(...) API against a local HTTP server that captured the actual POST /api/conversations request.

Relevant output:

remote_conversation_created= RemoteConversation
payload_user_id= None
payload_tags= {'automationid': 'auto-1'}

This shows the old SDK forwarded tags but dropped the caller-provided user_id, preventing the server from attributing the Laminar trace to that user.

Step 2 — Apply the PR's changes:
Checked out neubig/automation-laminar-attribution at 6c2e02b017aba72dbbfdcdce79595392eb55c994.

Step 3 — Re-run with the fix in place:
Ran the same remote SDK conversation creation path on the PR branch.

Relevant output:

remote_conversation_created= RemoteConversation
payload_user_id= user-42
payload_tags= {'automationid': 'auto-1'}

This confirms the remote create request now includes both user_id and tags, so agent-server has the attribution fields needed for Laminar trace user/session filtering.

Issues Found

None from functional QA. CI is not fully green yet: PR Description Check/Validate PR description is failing and 7 checks were still pending when reviewed.

This QA review was created by an AI agent (OpenHands) on behalf of the user.

@neubig neubig added the review-this This label triggers a PR review by OpenHands label Jun 9, 2026

all-hands-bot commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator

Review complete.

This review was performed through OpenHands Cloud Automation. You can log in and view the conversation here.

@all-hands-bot all-hands-bot left a comment

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.

Code Review: Add conversation attribution to Laminar traces

🟢 Good taste - Elegant, minimal change that solves a real observability problem.

Summary

This PR adds two pieces of attribution plumbing to connect automation conversations with their owning users in Laminar traces:

  1. Tags as span attributes: Conversation tags (e.g., automationid, automationrunid) are now attached as conversation.tags.<key> attributes on the root span, enabling filtering in Laminar.

  2. User ID forwarding: Remote conversations now forward user_id in the create payload so the agent server can call Laminar.set_trace_user_id().

Analysis

[IMPROVEMENT OPPORTUNITIES] (minor - worth considering)

  • [openhands-sdk/openhands/sdk/conversation/base.py, Line 162-173] Simplification: The if attributes: / else: branching creates duplicate start_root_span() calls. Since start_root_span() already accepts attributes=None, this can be simplified to always pass attributes=_conversation_tag_attributes(tags). The conditional adds no value.
# Current (duplicate calls):
if attributes:
    self._observability_root_span = start_root_span(..., attributes=attributes)
else:
    self._observability_root_span = start_root_span(...)

# Simplified:
self._observability_root_span = start_root_span(
    "conversation",
    session_id=session_id,
    user_id=user_id,
    attributes=_conversation_tag_attributes(tags),
)

This is a stylistic suggestion only - the code is correct and functional as-is.

What's Good

  • Clean separation of concerns: _conversation_tag_attributes() helper keeps the prefix logic in one place
  • Backward-compatible: all new parameters have | None defaults
  • Appropriate test coverage with mocked unit tests + functional QA verification
  • No breaking changes to existing APIs
  • Correct use of contextlib.suppress(Exception) for Laminar calls (fail silently, don't crash application)

Risk Assessment

  • [Overall PR] ⚠️ Risk Assessment: 🟢 LOW

The change is purely additive observability plumbing. It adds optional parameters with safe defaults, makes no breaking changes, and has appropriate test coverage. The external Laminar library calls are wrapped with exception suppression to prevent crashes.


VERDICT:Worth merging

Core logic is sound. The conditional simplification is a minor style suggestion that doesn't block approval.

KEY INSIGHT:
The _conversation_tag_attributes() helper provides a clean abstraction for the attribute prefix convention, making it easy to change the naming scheme in one place if needed.


This review was generated by an AI agent (OpenHands) on behalf of the user through OpenHands Automation. View conversation

Comment on lines +156 to 168
attributes = _conversation_tag_attributes(tags)
if attributes:
self._observability_root_span = start_root_span(
"conversation",
session_id=session_id,
user_id=user_id,
attributes=attributes,
)
else:
self._observability_root_span = start_root_span(
"conversation", session_id=session_id, user_id=user_id
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
attributes = _conversation_tag_attributes(tags)
if attributes:
self._observability_root_span = start_root_span(
"conversation",
session_id=session_id,
user_id=user_id,
attributes=attributes,
)
else:
self._observability_root_span = start_root_span(
"conversation", session_id=session_id, user_id=user_id
)
self._observability_root_span = start_root_span(
"conversation",
session_id=session_id,
user_id=user_id,
attributes=_conversation_tag_attributes(tags),
)

nit. Moreover, I think there is a test to update

# OTel context; we'll restore it on every entry point via ``use_span``.
self.span = Laminar.start_span(name)
if attributes:
with contextlib.suppress(Exception):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I suggest to move the contextlib.suppress inside the loop.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review-this This label triggers a PR review by OpenHands

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Track PR #3580: Add conversation attribution to Laminar traces

3 participants