Skip to content

feat: add automation OTEL tracing - #544

Merged
malhotra5 merged 11 commits into
mainfrom
feat/automation-otel-laminar-tracing
Oct 5, 2026
Merged

malhotra5 merged 11 commits into
mainfrom
feat/automation-otel-laminar-tracing

Conversation

@malhotra5

@malhotra5 malhotra5 commented Oct 1, 2026 •

Copy link
Copy Markdown
Member

Summary

  • Add optional Laminar/OTEL observability helpers for the automation service.
  • Initialize tracing on service startup using the SDK observability initialization pattern.
  • Emit spans/events for webhook receive, trigger matching, event routing, scheduler fires, dispatch, entrypoint start, and callback completion/failure.
  • Propagate OTEL/Laminar context plus generic OpenHands observability env vars into dispatched runs:
    • OPENHANDS_OBSERVABILITY_METADATA carries searchable high-cardinality attributes such as automation.id, automation.run_id, trigger source, conversation ID, sandbox ID, and bash command ID.
    • OPENHANDS_OBSERVABILITY_TAGS carries low-cardinality trace tags such as automation, automation.trigger:<type>, and automation.run_trigger:<source>.
    • OPENHANDS_OBSERVABILITY_PARENT_SPAN_CONTEXT is derived from the Laminar parent span context so SDK/agent-server conversation spans can attach to the automation run trace.
  • Pass generic observability tags/metadata into prompt/plugin preset SDK conversations through openhands.sdk.automation.automation_conversation_kwargs(...), so custom automations can use the same helper instead of duplicating env parsing.
  • Attach automation.callback.received to the propagated Laminar parent span context from SDK completion callbacks, so callbacks can share the dispatch/conversation trace.
  • Teach /sdk-version to return SDK install package specs and temporarily pin sandbox-installed SDK packages to feat: propagate automation observability context software-agent-sdk#5462 at 834e23fafcd30b81888cb6e4cafcd9cd1f2a9617, so automation sandboxes get the SDK/agent-server generic context propagation from that PR.
  • Add focused tests for automation observability env metadata, SDK install specs, setup scripts, and dispatcher context env propagation.

Related SDK PR

Requires draft SDK PR: OpenHands/software-agent-sdk#5462

Current SDK ref used by this automation branch:

834e23fafcd30b81888cb6e4cafcd9cd1f2a9617

Testing

Focused tests that passed:

uv run python -m pytest \
  tests/test_observability.py \
  tests/test_health.py::TestSdkVersionEndpoint \
  tests/test_test_tarball_setup.py \
  -q

Result:

9 passed, 2 warnings in 0.51s

Preset template tests:

uv run python -m pytest \
  tests/test_preset_router.py::TestPresetFileSyntax::test_prompt_setup_sh_fetches_sdk_version_from_api \
  tests/test_preset_router.py::TestPresetFileSyntax::test_plugin_setup_sh_fetches_sdk_version_from_api \
  tests/test_preset_router.py::TestPresetFileSyntax::test_preset_creates_its_conversation_under_the_derived_id \
  -q

Result:

4 passed, 2 warnings in 0.03s

Lint/type/syntax checks:

uv run ruff check \
  openhands/automation/observability.py \
  openhands/automation/dispatcher.py \
  openhands/automation/utils/version.py \
  openhands/automation/app.py \
  tests/test_observability.py \
  tests/test_health.py \
  tests/test_test_tarball_setup.py \
  tests/test_dispatcher.py

Result:

All checks passed!
uv run pyright \
  openhands/automation/observability.py \
  openhands/automation/dispatcher.py \
  openhands/automation/utils/version.py \
  openhands/automation/app.py \
  tests/test_observability.py \
  tests/test_health.py \
  tests/test_test_tarball_setup.py \
  tests/test_dispatcher.py

Result:

0 errors, 0 warnings, 0 informations
bash -n openhands/automation/presets/prompt/setup.sh && \
bash -n openhands/automation/presets/plugin/setup.sh && \
bash -n scripts/test_tarball/setup.sh

Result: exited 0.

Preset helper integration validation:

uv run python -m py_compile openhands/automation/presets/prompt/sdk_main.py openhands/automation/presets/plugin/sdk_main.py
uv run python -m pytest tests/test_health.py::TestSdkVersionEndpoint tests/test_preset_router.py::TestPresetFileSyntax::test_prompt_preset_sdk_main_syntax tests/test_preset_router.py::TestPresetFileSyntax::test_plugin_preset_sdk_main_syntax -q

Result:

5 passed, 2 warnings in 0.05s

Callback trace-parent validation:

uv run pytest tests/test_observability.py tests/test_health.py::TestSdkVersionEndpoint -q

Result:

8 passed, 2 warnings in 0.03s

Note: tests/test_router.py::TestCompleteRun::test_complete_run_uses_propagated_parent_span_context was also added, but the local targeted run could not start because Docker/testcontainers could not connect to /var/run/docker.sock in this environment.

Attempted dispatcher test that requires the repository PostgreSQL testcontainers fixture:

uv run python -m pytest \
  tests/test_dispatcher.py::TestExecuteRunDerivedConversationId::test_dispatch_propagates_generic_observability_context \
  -q

Result: errored before test execution because this environment has no Docker daemon for testcontainers:

docker.errors.DockerException: Error while fetching server API version: ('Connection aborted.', FileNotFoundError(2, 'No such file or directory'))

Earlier broader targeted tests had the same Docker limitation after non-container tests passed.

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

@github-actions github-actions Bot added the type: feat A new feature label Oct 1, 2026
@github-actions

github-actions Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Coverage

Warning

Your comment is too long (maximum is 65536 characters), so the coverage report was not added. See the job log for how to reduce it.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This review was posted by an AI agent (OpenHands).

Reviewed exact head 6826d93d7724b834b3a89bb9a8f1133db7995765 (feat: add automation OTEL tracing).

State: PR is open, non-draft, label type: feat; no prior reviews; the only issue comment is the bot coverage comment. All check runs on this head are green (unit-tests, backend, Build and Push Automation Image, pr-title, check-pr-artifacts, Validate PR description; the two cleanup checks are skipped). uv run ruff check on the changed files passes, and tests/test_observability.py passes locally (3 passed). The broader tests/test_ingest.py / tests/test_event_router.py suites require the PostgreSQL testcontainers fixture and could not run here (no Docker daemon), which matches the PR's own note.

Material finding (blocks the PR's headline "propagate OTEL trace context into dispatched runs" goal):

The injected trace context is never consumed downstream. inject_trace_context() calls the W3C propagator's inject(carrier), which only writes traceparent/tracestate into the dispatch env. The sandbox side runs the pinned openhands-sdk==1.50.1, whose observability layer reads upstream context solely from the LMNR_SPAN_CONTEXT env var (lmnr _initialize_context_from_env, reached through maybe_init_laminar()); nothing in the SDK, lmnr, or the agent server reads traceparent from the environment. I confirmed this empirically: exporting traceparent before maybe_init_laminar() does not parent a downstream span, while LMNR_SPAN_CONTEXT produced by Laminar.serialize_span_context() does. As written, the dispatch span's trace id is not propagated, so sandbox conversation/agent spans start disconnected traces. The fix is to emit LMNR_SPAN_CONTEXT (e.g. carrier["LMNR_SPAN_CONTEXT"] = Laminar.serialize_span_context()) from inside the active dispatch span rather than relying on propagate.inject.

See the inline comment for the precise location. Everything else in the diff (span/event wiring in ingest, event_router, scheduler, dispatcher, router, and the preset observability metadata) looks consistent with the repository guidance and is a no-op when observability is unconfigured.

🔄 CHANGES REQUESTED

Comment thread openhands/automation/observability.py

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This review was posted by an AI agent (OpenHands).

Re-reviewed exact head 20cb114709ac9da188cb00b197e6b4e802709efe (the PR moved from 6826d93; the author pushed fix: propagate Laminar span context). Scope check: the change belongs in this repository — it instruments the automation service's own webhook/trigger/routing/scheduler/dispatch/callback paths and passes correlation metadata into the SDK conversations it dispatches, consistent with AGENTS.md's ownership of scheduling, dispatch, and run lifecycle.

Previous blocking finding — resolved. My earlier review flagged that inject_trace_context() only wrote the W3C traceparent/tracestate header into the dispatch env, which the sandbox-side SDK (openhands-sdk==1.50.1) never reads; it consumes upstream context solely from LMNR_SPAN_CONTEXT. Commit 20cb114 adds carrier["LMNR_SPAN_CONTEXT"] = Laminar.serialize_span_context() before the W3C injection, and _execute_run_safe now wraps _execute_run in the automation.run.dispatch span, so serialization runs with that span active. I verified the fix end-to-end: injecting inside an active dispatch span yields both LMNR_SPAN_CONTEXT and traceparent, and a fresh process started with those env vars produces a downstream SDK span whose trace id matches the dispatch span — i.e. the propagated context now has a real consumer.

Verification on this head:

  • tests/test_observability.py passes locally (4 passed), including the new test_inject_trace_context_sets_laminar_span_context.
  • uv run ruff check and uv run pyright on the changed modules are clean.
  • unit-tests and backend checks are green.

One non-blocking CI note: the Build and Push Automation Image (Docker) check on this head failed. It is unrelated to this diff — the failure is an apt-get solver error (Unable to correct problems, you have held broken packages) while installing libpq-dev git in base-image setup step [3/10], before any application code is copied. This PR does not touch containers/Dockerfile, pyproject.toml, or uv.lock, and the same workflow succeeds on other branches. It looks like a transient snapshot/apt issue rather than a regression from this change; a re-run should confirm.

No other material issues found. The added spans/events are no-ops when observability is unconfigured, and the preset observability metadata flows through supported SDK Conversation kwargs.

✅ APPROVED

Copy link
Copy Markdown
Member Author

Laminar verification after restarting with the pushed refs shows the webhook/event path is now stitched into the same trace as the run lifecycle.

Observed singular trace:

trace_id = 10184ebb-c840-49f2-baaf-4598df50f44f
run_id = 19dd8431-9b4e-4935-a983-5a43e3fa07a9
automation_id = 427c4603-9585-417a-a766-ebb9e78b9341
trigger_event_id = 1f99ab24-a076-484e-a078-50d396fc3a1e

Spans seen in that same trace:

18:48:58.883  automation.webhook.receive
18:48:58.887  automation.match.evaluate
18:48:59.027  automation.route.run_created
18:49:05.731  automation.run.dispatch
18:49:28.631  automation.conversation
18:49:28.667  conversation
18:49:28.668  automation.conversation
18:49:39.138  automation.callback.received

Key checks:

  • automation.route.run_created includes automation.run_id, automation.trigger_event_id, automation.event.source=local-test, and automation.event.key=ping.created.
  • automation.run.dispatch is no longer a root span. Its parent_span_id points back under automation.webhook.receive, confirming the dispatcher picked up the persisted parent span context.
  • Downstream conversation and automation.callback.received spans carry the same automation.run_id / automation.trigger_event_id in the same trace.

Older traces from before the restart are still split, which is expected because those runs predate the persisted parent-context changes.

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

Copy link
Copy Markdown
Member Author

Additional Laminar verification for a custom automation that manually dispatches conversations:

The custom script used automation_observability_headers() when creating conversations through the conversation API. That successfully propagated the automation trace context and correlation metadata into the spawned conversations.

Observed trace:

trace_id = f667dfd4-9ce3-5cf2-72fc-a22378f39e39
automation_id = 8cb57981-9087-427e-aa27-a98f7a984952
automation_name = Local Curl Event Fanout
run_id = 02503e6e-344d-493a-93a7-4068b15c9693
trigger_event_id = b4a704fc-3607-4b36-9887-bd3cec4fcd4c

The trace contains the webhook and automation lifecycle spans:

automation.webhook.receive
automation.match.evaluate
automation.route.run_created
automation.run.dispatch

It also contains two separate manually-created agent conversations in the same trace. They appear as sibling conversation branches under automation.run.dispatch, both named webhook-fanout-conversation and distinguished by custom metadata:

Conversation 1:

webhook-fanout-conversation        dispatch_index=1
conversation.send_message
conversation.arun
agent.astep
llm.openhands/gpt-6-sol

Conversation 2:

webhook-fanout-conversation        dispatch_index=2
conversation.send_message
conversation.arun
agent.astep
llm.openhands/gpt-6-sol

For both conversation roots, Laminar association metadata includes the expected automation fields:

automation.id = 8cb57981-9087-427e-aa27-a98f7a984952
automation.name = Local Curl Event Fanout
automation.run_id = 02503e6e-344d-493a-93a7-4068b15c9693
automation.trigger_event_id = b4a704fc-3607-4b36-9887-bd3cec4fcd4c
automation.run.trigger_source = event

Laminar path for the spawned conversations shows they are nested under the automation trace:

automation.webhook.receive
  -> automation.run.dispatch
  -> conversation
  -> webhook-fanout-conversation

One remaining custom-script integration note: the callback for this custom script was separate because the script did not yet include the same observability headers on its completion callback request. The callback can use the same pattern and include **automation_observability_headers() when POSTing to AUTOMATION_CALLBACK_URL, so automation.callback.received joins the same trace as well.

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

Copy link
Copy Markdown
Member Author

Laminar QA results

Validated the association tracing path against a local webhook-triggered automation after this branch was deployed locally.

Test automation:

  • Automation: Local association trace test no filter
  • Automation ID: bd0fb5f3-c82c-41fb-8aed-a2f7da85ef86
  • Run ID: 3408e8ed-7151-4959-a3e5-f4d8e1d8a222
  • Trigger event ID: f0747496-d99e-44d0-b52d-1c5321cac7e2
  • Conversation ID: 71f3a3d3-ecde-4594-a921-1bb206eb3a47
  • Laminar trace ID: 3682b6ca-a200-c21e-dd50-41bc9c8daf8f

Configured associations were successfully emitted on the trace:

{
  "external.customer_tier": "enterprise",
  "external.delivery_id": "local-assoc-test-001",
  "external.priority": "high",
  "github.issue.key": "OpenHands/automation#543",
  "github.issue.number": 543,
  "github.repository.full_name": "OpenHands/automation",
  "github.sender.login": "local-test-user"
}

I was able to discover the trace from the issue-number association alone with a Laminar query matching github.issue.number = 543, then fetch the full timeline by trace_id.

Observed single-trace waterfall:

automation.webhook.receive
  automation.match.evaluate
  automation.route.run_created
  automation.run.dispatch
    automation.conversation
    conversation
      conversation.send_message
      conversation.run
      conversation.arun
      agent.astep
      llm.openhands/gpt-6-sol
      TerminalAction
      FinishAction
    automation.callback.received

The association values appeared on the run-created anchor span and the conversation root span. Lower-level SDK spans are still correlated by trace parentage rather than repeating every high-cardinality association. For lookup safety, github.issue.key = OpenHands/automation#543 is a better product lookup than issue number alone, since issue numbers are only unique within a repository.

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

Copy link
Copy Markdown
Member Author

Created the SDK follow-up issue with the ready-for-dev label: OpenHands/software-agent-sdk#5464

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This review was posted by an AI agent (OpenHands).

Re-reviewed the current exact head e65faaa8cd174e8f5a493ee72578ef39d28b6f80 (the branch advanced well past the 20cb114 I last approved; it now adds persisted parent-span propagation, high-cardinality associations, and a cross-repo SDK pin). Scope check: this still belongs in this repository — it instruments the automation service's own webhook/match/route/scheduler/dispatch/callback lifecycle and threads correlation through the SDK conversations it dispatches, matching AGENTS.md ownership of scheduling, dispatch, and run lifecycle.

What I verified on this head:

  • The prior traceparent vs LMNR_SPAN_CONTEXT issue stays fixed: inject_trace_context() writes LMNR_SPAN_CONTEXT (via current_span_context() / Laminar.serialize_span_context()), and that value is copied to OPENHANDS_OBSERVABILITY_PARENT_SPAN_CONTEXT in the dispatch env. I confirmed the env var names match the SDK helper exactly (OPENHANDS_OBSERVABILITY_METADATA/_TAGS/_SPAN_NAME/_PARENT_SPAN_CONTEXT) and that openhands.sdk.automation.automation_conversation_kwargs exists at the pinned SDK ref 834e23fafcd30b81888cb6e4cafcd9cd1f2a9617.
  • Persisted parent context is wired consistently: event ingest captures current_span_context() inside automation.webhook.receive, stores it on the run (observability_parent_span_context, migration 031), and the dispatcher re-attaches with span(..., parent_span_context=...). complete_run reads the X-OpenHands-Observability-Parent-Span-Context header the SDK sends — matching the header name emitted by automation_observability_headers().
  • Focused tests pass locally: tests/test_observability.py, tests/test_observability_associations.py, tests/test_run_utils.py, tests/test_schemas.py, test_health.py::TestSdkVersionEndpoint, test_test_tarball_setup.py, and test_migration_history.py (49 passed; the new observability tests 13 passed). ruff check on the changed modules is clean.
  • CI for this head is green: unit-tests, backend, Build and Push Automation Image, description/artifact/title checks all success.
  • Association config is validated (key charset/length, ≤20 entries, JMESPath compile, scalar/≤1000-char value clamping) and evaluated defensively, so a malformed row cannot break run creation. JMESPath evaluation is not code execution, and outputs are JSON-encoded into env vars rather than shell-interpolated.

Non-blocking risk to flag (not a correctness bug on this head): get_sdk_package_specs() temporarily pins every sandbox's openhands-sdk/openhands-tools/openhands-workspace to git ref 834e23fafcd30b81888cb6e4cafcd9cd1f2a9617, which is the tip of the still-open, currently non-mergeable OpenHands/software-agent-sdk#5462. Because setup.sh resolves this spec on every run, any force-push/GC of that branch would break setup for all automations, and the automation branch must not ship past this pin before the SDK PR merges. The PR documents this as temporary with a tracked follow-up issue; worth confirming the SDK PR lands (or the pin is replaced with a released version) before this merges to main.

No other material issues found.

✅ APPROVED

enyst
enyst previously requested changes Oct 4, 2026

@enyst enyst left a comment •

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'm an AI agent (Claude, based on Opus 5.5) helping Engel Nyst (@enyst) with project work.

Request changes: the git SDK pin in /sdk-version should not ship as it stands. all-hands-bot's last approval called the pin a non-blocking risk, to be resolved before merge. That has not happened, and since that review main has moved in a way that makes the pin harmful.

  1. Sandboxes would install an older SDK than the service.

    • get_sdk_package_specs() (utils/version.py:14) sends every new prompt, plugin and test-tarball setup to software-agent-sdk@834e23f.
    • That commit is from before OpenHands/software-agent-sdk#5462 merged, and its openhands-sdk version is 1.50.1. #5462 merged on 2026-10-03 as 1b2b5c8. That was after v1.51.0, the latest release, so no release contains it yet. The branch has been deleted.
    • main now pins openhands-sdk==1.51.0 (#549). 834e23f and v1.51.0 have diverged: the pinned commit lacks #5358, #5406 and the release commit.

    After merge, /sdk-version would report version: 1.51.0 while sandboxes install 1.50.1-based code from git. The pin also replaces PyPI wheels with a clone of the SDK repo and three source builds on every fresh sandbox, whether or not tracing is configured.

  2. The presets now require an unreleased SDK module. presets/prompt/sdk_main.py:195 and presets/plugin/sdk_main.py:192 import openhands.sdk.automation at module level, and v1.51.0 does not have it. So dropping packages and falling back to ==version would break new preset runs at import. To fix this, wait for an SDK release that contains #5462, bump openhands-sdk and openhands-workspace to it, and remove the git pin.

Separately, please update the PR description. The last commit adds a public contract change that the description does not mention: the observability_associations field on create, update, preset and draft requests, on responses and in git-sync YAML; the trigger_event_id column; and migration 031. The description also still lists #5462 as a draft.

What I verified beyond earlier reviews:

  • Migration: 031 revises 030, main's head, and leaves one Alembic head. It uses generic types, and the PostgreSQL COMMENTs are guarded.
  • Dependencies: there are no new direct dependencies. lmnr and OTEL come in through openhands-sdk, and jmespath was already used.
  • Off by default: tracing stays off unless LMNR_PROJECT_API_KEY or one of the OTEL_* endpoint variables is set. On main's SDK 1.51.0, the disabled check costs four os.getenv calls. On this head's 1.50.1 lock, the SDK's get_env also parses .env on every call, so the cheap path depends on merging main.
  • No secrets in traces: span and event attributes carry IDs, names, event source and key, subject key and filter expression. They carry no API keys, prompts or payloads, apart from association values that an automation owner configures (at most 20, each at most 1000 characters).
  • Sandbox env: the new env vars reach the sandbox through _shell_quote.
  • Merging: the branch merges cleanly with main, #302, #304 and #305. #517 has since merged with its own 031_add_webhook_event_id_header, so this branch's 031 now needs to become 032, revising 031.

Comment thread openhands/automation/utils/version.py Outdated
SDK_WORKSPACE_PACKAGE_NAME: Final[str] = "openhands-workspace"
SDK_REPOSITORY_URL: Final[str] = "https://github.com/OpenHands/software-agent-sdk.git"
# Temporary cross-repo pin for OpenHands/software-agent-sdk#5462 until released.
SDK_OBSERVABILITY_REF: Final[str] = "834e23fafcd30b81888cb6e4cafcd9cd1f2a9617"

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.

This sends every new sandbox to a pre-merge commit of software-agent-sdk#5462, whose openhands-sdk version is 1.50.1. #5462 has since merged as 1b2b5c8, after v1.51.0, so no release contains it yet. With main on 1.51.0 (#549), sandboxes would go back to 1.50.1-based code while /sdk-version reports 1.51.0. Please replace this with an SDK release that includes #5462, and bump pyproject.toml to match, before merging.

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
@malhotra5
malhotra5 force-pushed the feat/automation-otel-laminar-tracing branch from 3d9ef05 to 5af1f40 Compare October 5, 2026 14:48
Use published OpenHands SDK packages instead of the temporary git commit pin.\n\nCo-authored-by: openhands <openhands@all-hands.dev>
@malhotra5
malhotra5 force-pushed the feat/automation-otel-laminar-tracing branch from 5af1f40 to f6de25f Compare October 5, 2026 14:49
@malhotra5
malhotra5 dismissed enyst’s stale review October 5, 2026 14:50

fixed review concerns

@malhotra5
malhotra5 merged commit 2da02a5 into main Oct 5, 2026
9 checks passed
@openhands-release-bot openhands-release-bot Bot added the released: 1.19.0 Shipped in 1.19.0 label Oct 5, 2026
@openhands-release-bot

Copy link
Copy Markdown
Contributor

🚀 Released in 1.19.0.

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

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants