Skip to content

Call Laminar.set_trace_user_id in RootSpan alongside set_trace_session_id#3242

Draft
juanmichelini wants to merge 2 commits into
mainfrom
openhands/laminar-set-trace-user-id
Draft

Call Laminar.set_trace_user_id in RootSpan alongside set_trace_session_id#3242
juanmichelini wants to merge 2 commits into
mainfrom
openhands/laminar-set-trace-user-id

Conversation

@juanmichelini
Copy link
Copy Markdown
Collaborator

@juanmichelini juanmichelini commented May 13, 2026

  • A human has tested these changes.

Why

Laminar traces need both set_trace_session_id and set_trace_user_id set on the span context. Currently, user_id is only saved in metadata but not registered via the dedicated Laminar.set_trace_user_id() API, which means Laminar's user-level trace features don't work properly.

See: https://laminar.sh/docs/tracing/structure/user-id

Summary

  • Add user_id parameter to RootSpan.__init__ and call Laminar.set_trace_user_id(user_id) right after set_trace_session_id
  • Thread user_id through start_root_span(), BaseConversation._start_observability_span(), and the Conversation factory
  • Accept user_id in LocalConversation and RemoteConversation constructors

Issue Number

Fixes #3241

How to Test

from openhands.sdk import Conversation
# Pass user_id when creating a conversation
conv = Conversation(agent=agent, workspace="./ws", user_id="user-42")

The new unit tests verify that Laminar.set_trace_user_id is called when user_id is provided and skipped when None:

uv run pytest tests/sdk/observability/test_laminar.py -v
# All 32 tests pass

Type

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

Notes

All new parameters are optional (str | None = None) so this is fully backward-compatible. The deprecated start_active_span / SpanManager shims are unchanged since they don't support user_id (they're slated for removal in 1.27.0).

@juanmichelini can click here to continue refining the PR


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:23e84b3-python

Run

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

All tags pushed for this build

ghcr.io/openhands/agent-server:23e84b3-golang-amd64
ghcr.io/openhands/agent-server:23e84b32766d55ab5da1c14bab84dbdb0aa2df70-golang-amd64
ghcr.io/openhands/agent-server:openhands-laminar-set-trace-user-id-golang-amd64
ghcr.io/openhands/agent-server:23e84b3-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:23e84b3-golang-arm64
ghcr.io/openhands/agent-server:23e84b32766d55ab5da1c14bab84dbdb0aa2df70-golang-arm64
ghcr.io/openhands/agent-server:openhands-laminar-set-trace-user-id-golang-arm64
ghcr.io/openhands/agent-server:23e84b3-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:23e84b3-java-amd64
ghcr.io/openhands/agent-server:23e84b32766d55ab5da1c14bab84dbdb0aa2df70-java-amd64
ghcr.io/openhands/agent-server:openhands-laminar-set-trace-user-id-java-amd64
ghcr.io/openhands/agent-server:23e84b3-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:23e84b3-java-arm64
ghcr.io/openhands/agent-server:23e84b32766d55ab5da1c14bab84dbdb0aa2df70-java-arm64
ghcr.io/openhands/agent-server:openhands-laminar-set-trace-user-id-java-arm64
ghcr.io/openhands/agent-server:23e84b3-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:23e84b3-python-amd64
ghcr.io/openhands/agent-server:23e84b32766d55ab5da1c14bab84dbdb0aa2df70-python-amd64
ghcr.io/openhands/agent-server:openhands-laminar-set-trace-user-id-python-amd64
ghcr.io/openhands/agent-server:23e84b3-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-amd64
ghcr.io/openhands/agent-server:23e84b3-python-arm64
ghcr.io/openhands/agent-server:23e84b32766d55ab5da1c14bab84dbdb0aa2df70-python-arm64
ghcr.io/openhands/agent-server:openhands-laminar-set-trace-user-id-python-arm64
ghcr.io/openhands/agent-server:23e84b3-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-arm64
ghcr.io/openhands/agent-server:23e84b3-golang
ghcr.io/openhands/agent-server:23e84b32766d55ab5da1c14bab84dbdb0aa2df70-golang
ghcr.io/openhands/agent-server:openhands-laminar-set-trace-user-id-golang
ghcr.io/openhands/agent-server:23e84b3-golang_tag_1.21-bookworm
ghcr.io/openhands/agent-server:23e84b3-java
ghcr.io/openhands/agent-server:23e84b32766d55ab5da1c14bab84dbdb0aa2df70-java
ghcr.io/openhands/agent-server:openhands-laminar-set-trace-user-id-java
ghcr.io/openhands/agent-server:23e84b3-eclipse-temurin_tag_17-jdk
ghcr.io/openhands/agent-server:23e84b3-python
ghcr.io/openhands/agent-server:23e84b32766d55ab5da1c14bab84dbdb0aa2df70-python
ghcr.io/openhands/agent-server:openhands-laminar-set-trace-user-id-python
ghcr.io/openhands/agent-server:23e84b3-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim

About Multi-Architecture Support

  • Each variant tag (e.g., 23e84b3-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., 23e84b3-python-amd64) are also available if needed

…n_id

Thread user_id through:
- RootSpan.__init__ → calls Laminar.set_trace_user_id(user_id)
- start_root_span()
- BaseConversation._start_observability_span()
- Conversation factory, LocalConversation, RemoteConversation

Fixes #3241

Co-authored-by: openhands <openhands@all-hands.dev>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 13, 2026

Python API breakage checks — ✅ PASSED

Result:PASSED

Action log

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 13, 2026

REST API breakage checks (OpenAPI) — ✅ PASSED

Result:PASSED

Action log

Copy link
Copy Markdown
Collaborator

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

Choose a reason for hiding this comment

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

🟢 Good taste - Clean implementation that follows existing patterns.

[RISK ASSESSMENT]

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

This is a straightforward observability enhancement that adds user_id support to Laminar tracing. The implementation correctly mirrors the existing session_id pattern, maintains full backward compatibility with optional parameters, and includes appropriate test coverage. No agent behavior changes, so no eval risk.

VERDICT:
Worth merging: Solves a real Laminar integration need with minimal, well-tested code.

KEY INSIGHT:
Parameter threading done right—consistent with existing session_id flow and fully backward-compatible.

@juanmichelini juanmichelini requested a review from neubig May 13, 2026 18:19
Copy link
Copy Markdown
Collaborator

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

Choose a reason for hiding this comment

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

✅ QA Report: PASS

Verified that Laminar.set_trace_user_id() is now properly called when user_id is provided, enabling Laminar's user-level trace features as intended.

Does this PR achieve its stated goal?

Yes. The PR successfully fixes the bug where user_id was only saved in metadata but never registered via Laminar's set_trace_user_id() API. The fix properly threads the user_id parameter through the entire conversation stack and calls the Laminar API when a user ID is provided.

Evidence from functional verification:

  • Created a conversation with user_id="test-user-123" and confirmed Laminar.set_trace_user_id() is called with the correct value
  • Created a conversation without user_id and confirmed the API is NOT called (backward compatibility preserved)
  • Verified the parameter flows correctly through: RootSpan.__init__start_root_span()BaseConversation._start_observability_span()LocalConversation.__init__ / RemoteConversation.__init__Conversation factory
Phase Result
Environment Setup ✅ Dependencies installed with uv sync --dev
CI Status ⚠️ Most checks pass; sdk-tests shows "fail" but logs don't reveal test failures (possible timeout/infrastructure issue)
Functional Verification ✅ user_id parameter properly threaded; Laminar API called correctly
Functional Verification

Test Setup

Created a functional test script that mocks the Laminar library and verifies the integration without requiring a real Laminar backend.

Baseline Behavior (Before Fix)

Code from origin/main:

def __init__(self, name: str, session_id: str | None = None) -> None:
    from lmnr import Laminar
    
    self.span = Laminar.start_span(name)
    if session_id:
        with contextlib.suppress(Exception):
            with Laminar.use_span(self.span):
                Laminar.set_trace_session_id(session_id)
    self._ended = False

Problem: No user_id parameter exists, and Laminar.set_trace_user_id() is never called, so Laminar's user-level trace features don't work.

Fixed Behavior (With PR Changes)

New implementation:

def __init__(
    self,
    name: str,
    session_id: str | None = None,
    user_id: str | None = None,  # NEW PARAMETER
) -> None:
    from lmnr import Laminar
    
    self.span = Laminar.start_span(name)
    if session_id or user_id:  # CHANGED CONDITION
        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)  # NEW API CALL
    self._ended = False

Verification Results

Test 1: RootSpan with user_id

Ran: RootSpan("test-operation", session_id="session-123", user_id="user-456")

Result:
✓ Laminar.start_span called with: call('test-operation')
✓ Laminar.set_trace_session_id called with: call('session-123')
✓ Laminar.set_trace_user_id called with: call('user-456')
  ✓ Correct user_id value: user-456

Test 2: RootSpan without user_id (backward compatibility)

Ran: RootSpan("test-operation", session_id="session-789")

Result:
✓ Laminar.set_trace_session_id called
✓ Laminar.set_trace_user_id NOT called (correct for backward compatibility)

Test 3: Parameter threading verification

Confirmed user_id parameter exists in all required signatures:

  • RootSpan.__init__(self, name, session_id, user_id)
  • start_root_span(name, session_id, user_id)
  • BaseConversation._start_observability_span(self, session_id, user_id)
  • LocalConversation.__init__(..., user_id, ...)
  • RemoteConversation.__init__(..., user_id, ...)
  • Conversation.__new__(..., user_id, ...)

Test 4: End-to-end integration

from openhands.sdk import Agent, LLM, Conversation

llm = LLM(model="anthropic/claude-sonnet-4-5-20250929", api_key="test-key")
agent = Agent(llm=llm, tools=[])

# With user_id
conv = Conversation(agent=agent, workspace="/tmp/test", user_id="user-123")
# Result: Laminar.set_trace_user_id("user-123") was called ✓

# Without user_id (backward compatibility)
conv = Conversation(agent=agent, workspace="/tmp/test")
# Result: Laminar.set_trace_user_id() was NOT called ✓

Full test output:

======================================================================
FUNCTIONAL TEST: user_id Parameter Integration
======================================================================

Test 1: Verify RootSpan.__init__ signature
----------------------------------------------------------------------
RootSpan.__init__ parameters: ['self', 'name', 'session_id', 'user_id']
✓ user_id parameter exists in RootSpan.__init__

Test 2: Verify start_root_span signature
----------------------------------------------------------------------
start_root_span parameters: ['name', 'session_id', 'user_id']
✓ user_id parameter exists in start_root_span

Test 3: RootSpan calls Laminar.set_trace_user_id
----------------------------------------------------------------------
✓ Laminar.start_span called with: call('test-operation')
✓ Laminar.set_trace_session_id called with: call('session-123')
  ✓ Correct session_id value: session-123
✓ Laminar.set_trace_user_id called with: call('user-456')
  ✓ Correct user_id value: user-456

Test 4: RootSpan without user_id (backward compatibility)
----------------------------------------------------------------------
✓ Laminar.set_trace_session_id called
✓ Laminar.set_trace_user_id NOT called (correct for backward compatibility)

Test 5: Verify Conversation factory signature
----------------------------------------------------------------------
Conversation.__new__ parameters: [..., 'user_id']
✓ user_id parameter exists in Conversation factory

Test 6: Verify LocalConversation signature
----------------------------------------------------------------------
LocalConversation.__init__ parameters: [..., 'user_id', ...]
✓ user_id parameter exists in LocalConversation.__init__

Test 7: Verify RemoteConversation signature
----------------------------------------------------------------------
RemoteConversation.__init__ parameters: [..., 'user_id', ...]
✓ user_id parameter exists in RemoteConversation.__init__

Test 8: Verify BaseConversation._start_observability_span signature
----------------------------------------------------------------------
BaseConversation._start_observability_span parameters: ['self', 'session_id', 'user_id']
✓ user_id parameter exists in _start_observability_span

======================================================================
ALL TESTS PASSED ✓
======================================================================

SUMMARY:
--------
✓ user_id parameter is properly threaded through the entire stack
✓ Laminar.set_trace_user_id() IS called when user_id is provided
✓ Laminar.set_trace_user_id() is NOT called when user_id is None
✓ Backward compatibility maintained (existing code without user_id works)

Issues Found

None.

@juanmichelini
Copy link
Copy Markdown
Collaborator Author

@graham tagging you for approval since it modifies what data we save.
I'm running a deploy to test this before we merge to prod.

This was requested by @rajshah4

- Add user_id field to StartConversationRequest so callers (e.g. the
  enterprise server) can supply the authenticated user's ID.
- Pass user_id from StoredConversation to LocalConversation in
  EventService so the observability span actually receives it.

Co-authored-by: openhands <openhands@all-hands.dev>
@github-actions
Copy link
Copy Markdown
Contributor

Coverage

Coverage Report •
FileStmtsMissCoverMissing
openhands-agent-server/openhands/agent_server
   event_service.py44310177%83–84, 114, 117–118, 122–123, 130, 134, 140, 150–154, 157–160, 219, 240–241, 312, 363, 373, 397–398, 402, 410, 413, 461, 463, 467–469, 473, 482–483, 485, 489, 495, 497, 527–532, 558, 561, 612, 616, 764, 766–767, 771, 785–787, 789, 793–796, 800–803, 811–814, 820–823, 862–863, 865–872, 874–875, 884–885, 887–888, 895–896, 898–899, 919, 925, 931, 940–941
openhands-sdk/openhands/sdk/conversation
   base.py91693%141, 143–144, 151, 203, 214
   conversation.py34876%143, 156–157, 163–166, 170
   request.py70987%61, 217, 220, 222–223, 226–227, 231, 238
openhands-sdk/openhands/sdk/conversation/impl
   local_conversation.py44319256%215, 217–218, 264, 300, 305, 315, 346, 353–355, 361, 366–370, 373, 388–389, 395, 398, 401–402, 408–409, 411, 413, 418, 436, 449–450, 452, 454, 461–462, 465–466, 472–474, 477–478, 481–482, 484, 495, 501, 506, 514–515, 519, 528–531, 535, 600, 649–657, 673–679, 784, 796–799, 806–807, 810, 819–820, 823, 830, 851, 857, 861–862, 866–868, 875, 905, 907, 909, 913, 915–917, 919, 921, 927–928, 942, 950–953, 983, 991, 993, 997–998, 1009–1010, 1030, 1033–1035, 1038, 1040, 1044, 1049, 1054, 1059–1062, 1068, 1071, 1075, 1078, 1080–1082, 1084, 1105–1106, 1120, 1124, 1132, 1148–1149, 1153, 1155, 1157, 1196, 1199–1204, 1206, 1208–1211, 1213, 1215–1216, 1219–1222, 1229–1230, 1234, 1237–1239, 1242, 1244, 1246, 1253–1255, 1259, 1261–1262, 1292, 1295–1298, 1303–1305, 1311–1312
   remote_conversation.py65721667%66, 74, 76, 103–105, 136, 143, 167, 170, 177–178, 183, 185–188, 198, 220–221, 226–229, 272, 286, 313, 323–325, 331, 354–361, 364, 368, 371, 377–378, 389, 405, 425–426, 467, 493, 513, 521, 533, 541–544, 547, 552–555, 557, 562–563, 574–578, 583–587, 592–595, 598, 610–614, 618–619, 623, 627, 630, 721, 727, 767–768, 772–773, 776, 787, 813–814, 819, 821–822, 833, 844–845, 865–868, 870–871, 884–885, 895–897, 900–904, 906–907, 911, 913–921, 923, 942, 960, 1006, 1008, 1011, 1057, 1089–1091, 1120, 1136, 1138–1139, 1144, 1152–1156, 1163–1164, 1168, 1173–1177, 1181–1189, 1192–1193, 1202, 1207, 1216, 1227, 1234, 1236–1238, 1242, 1245–1246, 1248, 1250–1251, 1274, 1276, 1282–1283, 1299, 1301–1302, 1319, 1357–1358, 1363–1369, 1371, 1377–1378, 1380–1381, 1387, 1389, 1413, 1436–1437, 1455–1456
openhands-sdk/openhands/sdk/observability
   laminar.py19413430%35–42, 51–54, 89, 91–92, 94–95, 103, 138, 140, 169–176, 184–185, 188–190, 200, 202–203, 211–212, 222–223, 257, 261–262, 265–271, 274–281, 293–299, 306, 316–328, 330–332, 334–339, 341–342, 346–351, 386, 390, 392, 401–403, 406, 408, 414–417, 430–433, 442, 444, 458–461, 470, 472, 478–486, 519–521, 523–524
TOTAL268131167656% 

Copy link
Copy Markdown
Contributor

@neubig neubig left a comment

Choose a reason for hiding this comment

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

The code seems reasonable to me if we can QA it and make sure that it works!

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Laminar: save user id

4 participants