Skip to content

feat: expose observability fields on app conversations - #130

Merged
juanmichelini merged 3 commits into
mainfrom
expose-app-conversation-observability-fields-enterprise
Aug 6, 2026
Merged

feat: expose observability fields on app conversations#130
juanmichelini merged 3 commits into
mainfrom
expose-app-conversation-observability-fields-enterprise

Conversation

@juanmichelini

@juanmichelini juanmichelini commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Summary

  • add observability_metadata, observability_tags, and observability_span_name to app conversation start requests
  • forward API-provided observability fields into both OpenHands and ACP StartConversationRequest builders
  • update enterprise frontend request typing for these fields
  • add focused unit coverage for parsing and forwarding behavior

Ports OpenHands/sandbox-server#3 to enterprise.

Test plan

  • uv run --group test pytest tests/unit/app_server/test_live_status_app_conversation_service.py -q -k 'observability_fields or forwards_api_observability_fields'
  • uv run ruff check openhands/app_server/app_conversation/app_conversation_models.py openhands/app_server/app_conversation/live_status_app_conversation_service.py tests/unit/app_server/test_live_status_app_conversation_service.py

Notes:

  • Full targeted unit file currently has an existing environment-sensitive failure in test_configure_llm_and_mcp_openhands_model_no_base_urls because OPENHANDS_PROVIDER_BASE_URL resolves to https://llm-proxy.eval.all-hands.dev in this environment.
  • Frontend typecheck was not run because frontend/node_modules is not installed (react-router command missing).

Enterprise server image for this PR:

ghcr.io/openhands/enterprise-server:sha-13757ab

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

Coverage report

Click to see where and how coverage changed

FileStatementsMissingCoverageCoverage
(new stmts)
Lines missing
  openhands/app_server/app_conversation
  app_conversation_models.py
  live_status_app_conversation_service.py 1661-1662, 1666-1671, 2016-2017, 2226-2247, 2381, 2527-2538, 2547-2552
Project Total  

This report was generated by python-coverage-comment-action

@juanmichelini
juanmichelini force-pushed the expose-app-conversation-observability-fields-enterprise branch from 1860de8 to 7e97c22 Compare August 6, 2026 15:06
@juanmichelini juanmichelini changed the title Expose observability fields on app conversations feat: expose observability fields on app conversations Aug 6, 2026
@github-actions github-actions Bot added the type: feat A new feature label Aug 6, 2026
@aivong-openhands

Copy link
Copy Markdown
Contributor

Impact assessment: self-hosted / customer installs vs SaaS

Verdict: Additive, opt-in, and inert for existing callers — this adds three optional observability fields (observability_metadata, observability_tags, observability_span_name) to the app-conversation start API and forwards them into the OpenHands and ACP StartConversationRequest builders. No surface changes behavior unless an API client explicitly sends these fields; the product UI never sets them, and there is no host/WEB_HOST branching. There is no gating flag, but none is needed because the default (None) reproduces prior behavior exactly, and caller-supplied values can never overwrite server-set trace identity keys.

What changed

  1. frontend/.../v1-conversation-service.types.ts — adds ObservabilityMetadataValue type and three optional fields on V1AppConversationStartRequest (lines 56–80). Type-only. The single constructor of that body, V1ConversationService.createConversation in v1-conversation-service.api.ts:75-88, does not populate them — so the OpenHands web UI never emits these fields.
  2. app_conversation_models.py:257-274 — adds observability_metadata/observability_tags/observability_span_name to AppConversationStartRequest, typed as the SDK's ConversationObservabilityMetadata/Tags/SpanName, all defaulting to None. Consumers: the router endpoints (app_conversation_router.py:367, 1034, 1702) bind this as a request body (new optional fields auto-parsed), plus persistence via sql_app_conversation_start_task_service.py:66.
  3. live_status_app_conversation_service.py:
    • _start_app_conversation (call site :492) now forwards request.observability_* into _build_start_conversation_request_for_user.
    • _build_start_conversation_request_for_user (:1915) gains the three params, merges them into the computed observability_metadata/observability_tags and sets observability_span_name (:2226-2249), and forwards them to the ACP builder (call site :1997).
    • _build_acp_start_conversation_request (:2336) gains the same params and merge (:2527-2549).
    • New helper _extend_observability_tags (:1664) — dedup-append.
    • Both builders have exactly one call site each, both inside this file; there are no other callers to update.

Environment × flag matrix (before → after)

Deployment Does a caller send the fields? Laminar/OTel export configured? Result
Customer self-hosted (OHE / Replicated) No — product UI is type-only; no known API client → None → None Typically no (Laminar API key not provisioned) unchanged (additive-inert)
SaaS production Only direct API/eval clients (e.g. workbench wb) opt in → None → provided Yes changed (additive) for opt-in callers only; UI-started conversations unchanged
SaaS dev Same as prod Yes (dev project) unchanged unless caller opts in; then additive
SaaS staging Same as prod Yes unchanged unless caller opts in; then additive
SaaS feature previews Same as prod Per-preview unchanged unless caller opts in; then additive

Validation of data sources: SDK/agent-server support verified live — installed openhands-sdk==1.39.1 and openhands-agent-server==1.39.1 (the versions pinned in this PR's pyproject.toml/uv.lock) and confirmed ConversationObservabilityMetadata/Tags/SpanName import cleanly and that StartConversationRequest exposes all three fields, so the end-to-end plumbing is real on the pinned deps. Frontend non-population and the single-call-site facts are verified from the PR source. "Laminar configured per surface" is from deployment knowledge, not verified live — treat those cells as reasoned, not measured.

Behaviors that change, and for whom

  • For any caller that sends the fields (SaaS API/eval clients only, in practice): the conversation-root Laminar trace gains the caller's extra metadata keys, appended tags, and (if provided) a named child span. This affects observability grouping/routing only — no agent, sandbox, auth, or product behavior.
  • Merge precedence is safe by construction: _extend_observability_metadata keeps the existing (server-set) value on conflict and logs a warning, so a caller cannot overwrite server identity keys like repo_name, git_provider, conversation_id (this PR's own tests assert caller repo_name:'caller/repo' is dropped in favor of server 'test/repo'). Tags are dedup-appended after server tags.
  • Everyone else — every UI-started conversation on every surface, and effectively all self-hosted traffic — sees no change because the fields stay None.

Why customer / self-hosted installs are safe

  1. Additive with an inert default. Fields default to None; the merge blocks are guarded by if request_observability_*:, so an omitted field is a no-op — byte-identical StartConversationRequest to today.
  2. No caller populates them on self-hosted. The only body constructor (createConversation) is type-only; the product UI cannot emit these fields, so a self-hosted customer using the app sees nothing new.
  3. Even if an API client did send them, it's telemetry-only. The values flow solely into Laminar trace metadata/tags/span name; they do not influence agent construction, sandbox launch, secrets, repo/branch selection, or authorization. On installs without Laminar configured, they are collected and never exported.
  4. Caller cannot corrupt server trace identity. Server-set observability keys win on conflict; caller input can only add non-conflicting keys/tags.
  5. Posture note (kept separate from safety): this widens the API's accepted input surface with no feature flag to refuse the fields, and span names/tags become externally influenced. It is low-cardinality-validated at the SDK layer, but operators who treat trace tag/metadata content as trusted should know these can now originate from an API caller. This is a minor posture consideration, not a functional break.

Rollout note

This PR is OPEN (not merged). It reaches surfaces only through a new enterprise-server image build (the PR advertises ghcr.io/openhands/enterprise-server:sha-1860de8). SaaS surfaces pick it up via their normal image bump; self-hosted/OHE customers pick it up when they upgrade to a Replicated release built on top of this. Because the change is inert until a caller opts in, rollout carries no staged-flag requirement — deploying the image is safe on its own, and enabling the behavior is done per-caller by choosing to send the fields (today, only SaaS-side API/eval clients). No chart/KOTS value changes are required; the pinned SDK (1.39.1) already carries the field definitions.


This analysis was produced by an AI agent (OpenHands) on behalf of @aivong-openhands.

@aivong-openhands

Copy link
Copy Markdown
Contributor

Mutation review of the added tests

Coverage says a line ran; mutation testing says a line is pinned — break the behaviour on purpose and see if the suite notices. I hand-wrote 7 mutants against the PR's changes and ran them with tests/unit/app_server/test_live_status_app_conversation_service.py as the suite. Baseline: 159 passed in ~14s.

Controls — the tests work

These revert the PR's actual behaviour; all three died, so the direct-builder tests genuinely assert the merge:

Mutant Result
OpenHands builder: skip the caller-metadata merge ❌ caught
OpenHands builder: drop observability_span_name ❌ caught
ACP builder: skip the caller-metadata merge ❌ caught

What makes them land: test_build_request_forwards_api_observability_fields and test_forwards_api_observability_fields assert the entire observability_metadata dict and observability_tags list with == (not membership), so the caller keys, the server-key precedence, and the tag de-dup are all pinned by exact equality — that is a strong shape to assert on.

Survivors — the gaps

Mutant Result
M1: _start_app_conversation stops forwarding request.observability_* into the builder (all three → None) ✅ 159 passed
M2: the OpenHands builder stops forwarding the fields into the ACP builder (all three → None) ✅ 159 passed

Both new forwarding tests call the builders directly, so nothing exercises the two hops that actually carry the API request into them.

M1 — the API→builder wiring is unasserted (highest consequence)

The three fields are added to AppConversationStartRequest; _start_app_conversation is the one place that reads them off the parsed request (request_observability_metadata=request.observability_metadata, etc., ~line 508). If a refactor of that large async flow drops or renames one of those three lines, the feature silently no-ops for every caller and CI stays green — which is the whole point of the PR. The existing _start_app_conversation tests never set these fields, so they can't catch it.

Fix — drive the real flow and assert the builder receives the fields (mirrors the existing test_start_app_conversation_default_title_* scaffolding):

@patch('...live_status_app_conversation_service.AsyncRemoteWorkspace')
@patch('...live_status_app_conversation_service.ConversationInfo')
async def test_start_app_conversation_forwards_observability_to_builder(
    self, mock_conversation_info_class, mock_remote_workspace_class
):
    conversation_id = uuid4()
    self.mock_user_context.get_user_id = AsyncMock(return_value='u1')
    self.mock_user_context.get_user_info = AsyncMock(return_value=self.mock_user)

    mock_sandbox_spec = Mock(spec=SandboxSpecInfo)
    mock_sandbox_spec.working_dir = '/test/workspace'
    self.mock_sandbox.sandbox_spec_id = str(uuid4())
    self.mock_sandbox.id = str(uuid4())
    self.mock_sandbox.session_api_key = 'k'
    self.mock_sandbox.exposed_urls = [
        ExposedUrl(name=AGENT_SERVER, url='http://agent-server:8000', port=60000)
    ]
    self.mock_sandbox_service.get_sandbox = AsyncMock(return_value=self.mock_sandbox)
    self.mock_sandbox_spec_service.get_sandbox_spec = AsyncMock(
        return_value=mock_sandbox_spec
    )
    mock_remote_workspace_class.return_value = Mock()

    async def mock_wait_for_sandbox(task):
        task.sandbox_id = self.mock_sandbox.id
        yield task

    async def mock_run_setup_scripts(task, sandbox, workspace, agent_server_url, conversation_id):
        yield task

    self.service._wait_for_sandbox_start = mock_wait_for_sandbox
    self.service.run_setup_scripts = mock_run_setup_scripts

    mock_agent = Mock(spec=Agent)
    mock_agent.llm = Mock(spec=LLM)
    mock_agent.llm.model = 'gpt-4'
    mock_start_request = Mock(spec=StartConversationRequest)
    mock_start_request.agent = mock_agent
    mock_start_request.model_dump.return_value = {'test': 'data'}
    self.service._build_start_conversation_request_for_user = AsyncMock(
        return_value=mock_start_request
    )

    mock_conversation_info = Mock()
    mock_conversation_info.id = conversation_id
    mock_conversation_info_class.model_validate.return_value = mock_conversation_info
    mock_response = Mock()
    mock_response.json.return_value = {'id': str(conversation_id)}
    mock_response.raise_for_status = Mock()
    self.mock_httpx_client.post = AsyncMock(return_value=mock_response)
    self.mock_event_callback_service.save_event_callback = AsyncMock()

    request = AppConversationStartRequest(
        observability_metadata={'evaluation': 'wb'},
        observability_tags=['wb-rubric'],
        observability_span_name='mySpanName',
    )
    async for _ in self.service._start_app_conversation(request):
        pass

    self.service._build_start_conversation_request_for_user.assert_called_once()
    kwargs = self.service._build_start_conversation_request_for_user.call_args.kwargs
    assert kwargs['request_observability_metadata'] == {'evaluation': 'wb'}
    assert kwargs['request_observability_tags'] == ['wb-rubric']
    assert kwargs['request_observability_span_name'] == 'mySpanName'

Verified: this passes on the PR branch unmodified and fails with M1 applied.

M2 — the ACP routing hop is unasserted

test_forwards_api_observability_fields proves _build_acp_start_conversation_request merges correctly when called directly, but nothing proves _build_start_conversation_request_for_user actually forwards the fields into it on the ACP branch (~line 2011). Drop them at that hop and every ACP conversation loses the fields with the suite still green.

Fix — route through the OpenHands builder with ACP settings and assert the ACP builder receives them:

@patch('...live_status_app_conversation_service.get_default_tools', return_value=[])
@pytest.mark.asyncio
async def test_build_request_forwards_observability_to_acp_builder(self, _mock_tools):
    from openhands.sdk.settings import ACPAgentSettings

    self.mock_user.agent_settings = ACPAgentSettings(
        acp_server='claude-code',
        llm=LLM(model='claude-sonnet-4-5', api_key=None),
        agent_context=None,
    )
    self.mock_user_context.get_user_info.return_value = self.mock_user
    self.service._setup_secrets_for_git_providers = AsyncMock(return_value={})
    self.service._configure_llm_and_mcp = AsyncMock(
        return_value=(LLM(model='gpt-4', api_key=SecretStr('k')), {})
    )
    self.service._resolve_registered_marketplaces = AsyncMock(return_value=None)
    sentinel = Mock(spec=StartConversationRequest)
    self.service._build_acp_start_conversation_request = AsyncMock(return_value=sentinel)

    result = await self.service._build_start_conversation_request_for_user(
        sandbox=self.mock_sandbox,
        conversation_id=uuid4(),
        initial_message=None,
        system_message_suffix=None,
        git_provider=ProviderType.GITHUB,
        working_dir='/test/dir',
        remote_workspace=None,
        selected_repository='test/repo',
        selected_branch='feature-x',
        request_observability_span_name='mySpanName',
        request_observability_tags=['wb-rubric'],
        request_observability_metadata={'evaluation': 'wb'},
    )

    assert result is sentinel
    self.service._build_acp_start_conversation_request.assert_called_once()
    kwargs = self.service._build_acp_start_conversation_request.call_args.kwargs
    assert kwargs['request_observability_metadata'] == {'evaluation': 'wb'}
    assert kwargs['request_observability_tags'] == ['wb-rubric']
    assert kwargs['request_observability_span_name'] == 'mySpanName'

Verified: this passes on the PR branch unmodified and fails with M2 applied.

Not a test gap

  • I also mutated the two behaviours the direct tests are built around, and both were caught, so no action needed: making metadata conflicts last-write-wins (caller could overwrite server keys) → killed; making _extend_observability_tags skip its de-dup (caller tag appended twice) → killed. The exact-== assertions on the full dict/list are what pin these.
  • Server-key precedence is additionally guaranteed structurally: _extend_observability_metadata keeps the existing value and logs a warning on conflict, so a caller cannot clobber repo_name/git_provider/conversation_id regardless of test coverage.

This comment was generated by an AI assistant on behalf of the user.

@aivong-openhands

Copy link
Copy Markdown
Contributor

Suggestion: pin the observability validation contract (negative cases)

The added test_app_conversation_start_request_accepts_observability_fields covers the happy path — it proves valid values are accepted. It doesn't pin the other half: that invalid shapes are rejected. That rejection behavior is the real contract here, and it matters because the frontend type ObservabilityMetadataValue = string | number | boolean | string[] | number[] | boolean[] is strictly more permissive than the backend validators. For example, TS number[] permits [1, 1.5], but TraceMetadataValue in the SDK requires homogeneous numeric lists and rejects mixed [1, 1.5] (OpenTelemetry constraint). Nothing currently asserts that boundary, so a future loosening of the SDK validators — or a wrong assumption on the frontend — wouldn't trip a test.

These validators live in the SDK (openhands.sdk.conversation.types), outside this PR's diff, so they're also outside the scope of the mutation review posted above. This is a cheap, mock-free way to lock the contract at the enterprise boundary that consumes them:

import pytest
from pydantic import ValidationError

    @pytest.mark.parametrize(
        'kwargs',
        [
            # metadata: mixed-numeric list is not homogeneous (exactly what the
            # frontend `number[]` type would wrongly allow)
            {'observability_metadata': {'scores': [1, 1.5]}},
            # metadata: non-scalar value
            {'observability_metadata': {'nested': {'a': 1}}},
            # metadata: empty key
            {'observability_metadata': {'': 'x'}},
            # tags: empty-string tag
            {'observability_tags': ['ok', '']},
            # tags: not a list
            {'observability_tags': 'not-a-list'},
            # span name: illegal characters
            {'observability_span_name': 'bad name!'},
            # span name: exceeds the 128-char limit
            {'observability_span_name': 'x' * 129},
        ],
    )
    def test_app_conversation_start_request_rejects_invalid_observability_fields(
        self, kwargs
    ):
        with pytest.raises(ValidationError):
            AppConversationStartRequest(**kwargs)

This is ~15 lines in the file you've already touched, no new fixtures or mocks. It's optional/nice-to-have — the runtime validation itself already works; this just guards it from silent drift, which given the frontend/backend contract being hand-maintained is where I'd expect the next regression to come from.

This comment was generated by an AI assistant on behalf of the user.

@juanmichelini

Copy link
Copy Markdown
Contributor Author

Addressed the reviewer test-coverage concerns in 13757ab1:\n\n- Added API request → _build_start_conversation_request_for_user forwarding coverage for observability_*.\n- Added OpenHands builder → ACP builder forwarding coverage for observability_*.\n- Added negative validation coverage for invalid observability metadata/tags/span names at the enterprise request boundary.\n\nValidation run locally:\n- uv run pre-commit run --config ./dev_config/python/.pre-commit-config.yaml\n- uv run --group test pytest tests/unit/app_server/test_live_status_app_conversation_service.py -q -k 'observability_fields or forwards_api_observability_fields or forwards_observability_to_builder or forwards_observability_to_acp_builder'\n- cd frontend && npm run lint:fix && npm run build\n\nNote: make install-pre-commit-hooks and the git hook path fail in this local environment because Poetry resolves through Python 3.14 and hits the existing pyexpat dynamic-library error, so I ran the required checks explicitly via uv/npm.

@juanmichelini
juanmichelini merged commit 673ceec into main Aug 6, 2026
18 checks passed
@juanmichelini
juanmichelini deleted the expose-app-conversation-observability-fields-enterprise branch August 6, 2026 22:54
@openhands-release-bot openhands-release-bot Bot added the released: 1.51.0 Shipped in 1.51.0 label Aug 7, 2026
@openhands-release-bot

Copy link
Copy Markdown

🚀 Released in 1.51.0.

@juanmichelini

Copy link
Copy Markdown
Contributor Author

Screenshot:

image

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

Labels

released: 1.51.0 Shipped in 1.51.0 type: feat A new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants