Skip to content

feat: add OpenCode OTel support#109

Open
astefanutti wants to merge 1 commit into
mainfrom
feat/opencode-otel
Open

feat: add OpenCode OTel support#109
astefanutti wants to merge 1 commit into
mainfrom
feat/opencode-otel

Conversation

@astefanutti

@astefanutti astefanutti commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Replaces #85 (now pushed from upstream branch).

Summary

Enable OTel trace export for the OpenCode harness, matching the existing Claude Code support.

Changes

  • Harness: Implement build_otel_exec_env() and build_env_script_lines() with OpenCode-specific OTel env vars (OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_PROTOCOL, OTEL_BSP_SCHEDULE_DELAY=0). Set supports_otel = True.
  • Sandbox config: Add write_sandbox_config() hook to the Harness base class. OpenCode implementation writes opencode.json with experimental.openTelemetry: true (required by OpenCode to enable OTel). Add sandbox_config_mounts() to return (host, container) path pairs so backends don't hardcode container paths.
  • Podman backend: Call write_sandbox_config() during setup, mount config via sandbox_config_mounts().
  • OpenShell backend: Upload config via _upload_sandbox_config() using sandbox_config_mounts(). Delegate OTel env vars to the harness instead of hardcoding Claude-specific vars. Guard CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC to only set for Claude Code harness.
  • Docs: Add docs/otel-configuration.md documenting all OTel env vars per harness and why each is harness-specific.

Note

OpenCode's OTel trace export has an upstream limitation: process.exit() can kill spans before the batch processor flushes. OTEL_BSP_SCHEDULE_DELAY=0 mitigates this but may not catch all spans. Full fix depends on upstream OpenCode changes.

Summary by CodeRabbit

  • Documentation

    • Added comprehensive guide on OpenTelemetry configuration for different agent harnesses, covering harness-specific settings, environment variables, and runtime requirements.
  • New Features

    • Enhanced OpenTelemetry support with agent-specific telemetry configuration, exporter settings, and improved sandbox configuration management for better observability.

Enable OTel trace export for the OpenCode harness:

- Set OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_PROTOCOL, and
  OTEL_BSP_SCHEDULE_DELAY=0 in build_otel_exec_env() and
  build_env_script_lines(). BSP_SCHEDULE_DELAY=0 forces immediate span
  flush to work around OpenCode's process.exit() killing spans before
  the batch processor drains. Claude Code doesn't need it (manages its
  own flush lifecycle). OTEL_METRIC_EXPORT_INTERVAL is Claude-specific
  (different metrics SDK) and not set for OpenCode.
- Set supports_otel = True
- Add write_sandbox_config() hook to Harness base class; OpenCode
  implementation writes opencode.json with experimental.openTelemetry
- Add sandbox_config_mounts() to return (host, container) path pairs
  so backends don't hardcode container paths
- Podman backend calls write_sandbox_config() and mounts via
  sandbox_config_mounts()
- OpenShell backend uploads config via sandbox_config_mounts() and
  delegates OTel env to harness instead of hardcoding Claude-specific
  vars. CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC only set for Claude.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 15, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Harness gains two base-class no-op hooks — write_sandbox_config and sandbox_config_mounts — that backends call before container start. OpenCodeHarness implements these hooks to write opencode.json (optionally with experimental.openTelemetry: true) and return the mount path; it also sets supports_otel = True and updates build_env_script_lines/build_otel_exec_env to inject OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_PROTOCOL, and OTEL_BSP_SCHEDULE_DELAY=0. The Podman backend gains _resolve_sandbox_config and extends _build_vol_args with conditional ADC/config_default mounts and harness-provided sandbox config mounts. The OpenShell backend gains _upload_sandbox_config and delegates env var construction to harness.build_env_script_lines. Tests and documentation are updated accordingly.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 9 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
No Injection Vectors ⚠️ Warning CWE-78 shell injection in openshell/init.py line 91: container_path interpolated into bash -c command via extensible sandbox_config_mounts() interface; currently hardcoded but design allows uns... Use exec with array form (no bash -c) or construct shell-safe paths: exec(["mkdir", "-p", dirname], ["mv", filename, container_path]) with proper path handling.
✅ Passed checks (9 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: add OpenCode OTel support' accurately summarizes the main change—adding OpenTelemetry support to the OpenCode harness, which is the primary objective across all file modifications.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Contribution Quality And Spam Detection ✅ Passed No spam signals detected. PR is substantial multi-file feature work (OTel support), description shows codebase knowledge, includes tests. Real bugs in review are normal findings, not spam indicators.
No Hardcoded Secrets ✅ Passed No hardcoded secrets found. All credentials (ANTHROPIC_API_KEY, GCLOUD_CREDENTIALS, GCP_SERVICE_ACCOUNT_KEY) are read from os.environ. Test fixtures use placeholder values (sk-test, sk-test-key). N...
No Weak Cryptography ✅ Passed No banned cryptographic primitives (MD5, SHA1, DES, RC4, 3DES, Blowfish, ECB), custom crypto implementations, or insecure secret comparisons detected. base64 usage in podman.py is for credential fo...
No Privileged Containers ✅ Passed PR contains only Python source and documentation (.py, .md); no Kubernetes manifests, Helm templates, or Dockerfiles to check for privileged container configurations.
No Sensitive Data In Logs ✅ Passed No logging statements expose sensitive data. API keys, tokens, credentials, and PII are handled via environment variables and never logged. All logging calls use static strings or non-sensitive val...

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/agentic_ci/backends/openshell/__init__.py (1)

64-67: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Existing sandboxes skip config refresh, leaving OTEL state stale.

Line 66 returns before Line 82 runs. If the sandbox was created without OTEL and later reused with otel_port set, opencode.json is never rewritten with experimental.openTelemetry: true, so traces can stay disabled.

Suggested fix
         if sandbox.exists():
             log.section("Sandbox already exists")
+            self._upload_sandbox_config(otel_enabled=otel_port is not None)
             return
@@
-        self._upload_sandbox_config(otel_enabled=otel_port is not None)
+        self._upload_sandbox_config(otel_enabled=otel_port is not None)

Also applies to: 82-83

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/agentic_ci/backends/openshell/__init__.py` around lines 64 - 67, The
early return at line 64-67 (when sandbox already exists) prevents the OTEL
configuration refresh logic from running at lines 82-83, causing opencode.json
to never be updated with OTEL settings if a sandbox is reused with a new
otel_port value. Move the OTEL configuration and opencode.json writing logic
(currently at lines 82-83) to execute before the sandbox existence check, or
restructure it so that configuration refresh happens independently of sandbox
creation, ensuring that OTEL settings are always synchronized even for existing
sandboxes.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/agentic_ci/backends/openshell/__init__.py`:
- Around line 89-92: The sandbox.exec_cmd call in the upload block is vulnerable
to shell command injection because container_path is interpolated into a shell
string executed via bash -c. To fix this, refactor the upload method to avoid
using bash -c with interpolated paths. Instead, split the operations into
separate exec_cmd calls that pass paths as direct arguments rather than shell
string interpolation, or use shell-safe escaping if bash must be used.
Specifically, replace the single mkdir and mv command that combines
container_path and fname into a shell string with individual exec_cmd calls that
pass these paths as list arguments to avoid any shell metacharacter
interpretation.
- Around line 86-93: The temporary directory created by tempfile.mkdtemp() is
not cleaned up if an exception occurs during sandbox.upload() or
sandbox.exec_cmd() calls, since the shutil.rmtree() cleanup on the final line is
skipped when exceptions are raised. Wrap the entire block that uses config_dir
(from tempfile.mkdtemp through the loop with sandbox.upload and
sandbox.exec_cmd) in a try-finally block to ensure shutil.rmtree(config_dir,
ignore_errors=True) always executes regardless of exceptions, or alternatively
refactor to use tempfile.TemporaryDirectory as a context manager for automatic
cleanup.

In `@src/agentic_ci/harness.py`:
- Around line 383-398: The _SANDBOX_CONFIG_DIR constant hardcodes the path as
/sandbox/.config/opencode, but the Podman container contract specifies that
OpenCode config should be mounted to /home/agent-ci/.config/opencode (matching
the OPENCODE_CONFIG_DIR environment variable in Podman). Update the
_SANDBOX_CONFIG_DIR constant value to /home/agent-ci/.config/opencode so that
the sandbox_config_mounts method returns the correct destination path when
mounting the opencode.json file to the container.

---

Outside diff comments:
In `@src/agentic_ci/backends/openshell/__init__.py`:
- Around line 64-67: The early return at line 64-67 (when sandbox already
exists) prevents the OTEL configuration refresh logic from running at lines
82-83, causing opencode.json to never be updated with OTEL settings if a sandbox
is reused with a new otel_port value. Move the OTEL configuration and
opencode.json writing logic (currently at lines 82-83) to execute before the
sandbox existence check, or restructure it so that configuration refresh happens
independently of sandbox creation, ensuring that OTEL settings are always
synchronized even for existing sandboxes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Central YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Enterprise

Run ID: 05146be1-d898-48f2-a5ad-dc233a335b8e

📥 Commits

Reviewing files that changed from the base of the PR and between d85974e and 6841b8f.

📒 Files selected for processing (5)
  • docs/otel-configuration.md
  • src/agentic_ci/backends/openshell/__init__.py
  • src/agentic_ci/backends/podman.py
  • src/agentic_ci/harness.py
  • tests/test_harness.py

Comment on lines +86 to +93
config_dir = tempfile.mkdtemp(prefix="agentic-ci-config-")
self.harness.write_sandbox_config(config_dir, otel_enabled=otel_enabled)
for host_path, container_path in self.harness.sandbox_config_mounts(config_dir):
sandbox.upload(host_path)
fname = os.path.basename(host_path)
cmd = f"mkdir -p $(dirname {container_path}) && mv {fname} {container_path}"
sandbox.exec_cmd(["bash", "-c", cmd])
shutil.rmtree(config_dir, ignore_errors=True)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Temporary config directories leak on exceptions.

If sandbox.upload or sandbox.exec_cmd fails, Line 93 is skipped and host temp dirs accumulate.

Suggested fix
     def _upload_sandbox_config(self, otel_enabled=False):
         """Write harness-specific config and upload it to the sandbox."""
         config_dir = tempfile.mkdtemp(prefix="agentic-ci-config-")
-        self.harness.write_sandbox_config(config_dir, otel_enabled=otel_enabled)
-        for host_path, container_path in self.harness.sandbox_config_mounts(config_dir):
-            sandbox.upload(host_path)
-            fname = os.path.basename(host_path)
-            cmd = f"mkdir -p $(dirname {container_path}) && mv {fname} {container_path}"
-            sandbox.exec_cmd(["bash", "-c", cmd])
-        shutil.rmtree(config_dir, ignore_errors=True)
+        try:
+            self.harness.write_sandbox_config(config_dir, otel_enabled=otel_enabled)
+            for host_path, container_path in self.harness.sandbox_config_mounts(config_dir):
+                sandbox.upload(host_path)
+                fname = os.path.basename(host_path)
+                cmd = f"mkdir -p $(dirname {container_path}) && mv {fname} {container_path}"
+                sandbox.exec_cmd(["bash", "-c", cmd])
+        finally:
+            shutil.rmtree(config_dir, ignore_errors=True)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
config_dir = tempfile.mkdtemp(prefix="agentic-ci-config-")
self.harness.write_sandbox_config(config_dir, otel_enabled=otel_enabled)
for host_path, container_path in self.harness.sandbox_config_mounts(config_dir):
sandbox.upload(host_path)
fname = os.path.basename(host_path)
cmd = f"mkdir -p $(dirname {container_path}) && mv {fname} {container_path}"
sandbox.exec_cmd(["bash", "-c", cmd])
shutil.rmtree(config_dir, ignore_errors=True)
config_dir = tempfile.mkdtemp(prefix="agentic-ci-config-")
try:
self.harness.write_sandbox_config(config_dir, otel_enabled=otel_enabled)
for host_path, container_path in self.harness.sandbox_config_mounts(config_dir):
sandbox.upload(host_path)
fname = os.path.basename(host_path)
cmd = f"mkdir -p $(dirname {container_path}) && mv {fname} {container_path}"
sandbox.exec_cmd(["bash", "-c", cmd])
finally:
shutil.rmtree(config_dir, ignore_errors=True)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/agentic_ci/backends/openshell/__init__.py` around lines 86 - 93, The
temporary directory created by tempfile.mkdtemp() is not cleaned up if an
exception occurs during sandbox.upload() or sandbox.exec_cmd() calls, since the
shutil.rmtree() cleanup on the final line is skipped when exceptions are raised.
Wrap the entire block that uses config_dir (from tempfile.mkdtemp through the
loop with sandbox.upload and sandbox.exec_cmd) in a try-finally block to ensure
shutil.rmtree(config_dir, ignore_errors=True) always executes regardless of
exceptions, or alternatively refactor to use tempfile.TemporaryDirectory as a
context manager for automatic cleanup.

Comment on lines +89 to +92
sandbox.upload(host_path)
fname = os.path.basename(host_path)
cmd = f"mkdir -p $(dirname {container_path}) && mv {fname} {container_path}"
sandbox.exec_cmd(["bash", "-c", cmd])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

CWE-78: shell command is built from file paths and executed via bash -c.

Line 91 interpolates container_path into a shell string. Today paths are in-tree constants, but this hook is extensible; a future harness path with shell metacharacters turns this into command injection in the sandbox execution context.

Exploit scenario: a crafted mount target like /sandbox/x;touch /tmp/pwned results in arbitrary command execution when bash -c runs.

Suggested fix (avoid shell parsing)
         for host_path, container_path in self.harness.sandbox_config_mounts(config_dir):
             sandbox.upload(host_path)
             fname = os.path.basename(host_path)
-            cmd = f"mkdir -p $(dirname {container_path}) && mv {fname} {container_path}"
-            sandbox.exec_cmd(["bash", "-c", cmd])
+            target_dir = os.path.dirname(container_path)
+            sandbox.exec_cmd(["mkdir", "-p", target_dir])
+            sandbox.exec_cmd(["mv", fname, container_path])

As per coding guidelines, "Validate file paths (prevent path traversal)" and "No shell=True in subprocess".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/agentic_ci/backends/openshell/__init__.py` around lines 89 - 92, The
sandbox.exec_cmd call in the upload block is vulnerable to shell command
injection because container_path is interpolated into a shell string executed
via bash -c. To fix this, refactor the upload method to avoid using bash -c with
interpolated paths. Instead, split the operations into separate exec_cmd calls
that pass paths as direct arguments rather than shell string interpolation, or
use shell-safe escaping if bash must be used. Specifically, replace the single
mkdir and mv command that combines container_path and fname into a shell string
with individual exec_cmd calls that pass these paths as list arguments to avoid
any shell metacharacter interpretation.

Source: Coding guidelines

Comment thread src/agentic_ci/harness.py
Comment on lines +383 to +398
_SANDBOX_CONFIG_DIR = "/sandbox/.config/opencode"

def write_sandbox_config(self, config_dir, otel_enabled=False):
opencode_dir = os.path.join(config_dir, ".config", "opencode")
os.makedirs(opencode_dir, exist_ok=True)
config = {"$schema": "https://opencode.ai/config.json"}
if otel_enabled:
config["experimental"] = {"openTelemetry": True}
with open(os.path.join(opencode_dir, "opencode.json"), "w") as f:
json.dump(config, f, indent=2)

def sandbox_config_mounts(self, config_dir):
host_path = os.path.join(config_dir, ".config", "opencode", "opencode.json")
if os.path.exists(host_path):
return [(host_path, f"{self._SANDBOX_CONFIG_DIR}/opencode.json")]
return []

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Podman receives the wrong opencode.json destination path.

Line 383 hardcodes /sandbox/.config/opencode, but Podman OpenCode runs with OPENCODE_CONFIG_DIR=/home/agent-ci/.config/opencode (container contract). With Line 397, Podman mounts opencode.json into the OpenShell path, so OTEL enablement can be ignored in Podman.

Suggested fix (contract-level; backend-aware mount target)
# src/agentic_ci/harness.py
-class Harness(ABC):
+class Harness(ABC):
@@
-    def sandbox_config_mounts(self, config_dir):
+    def sandbox_config_mounts(self, config_dir, runtime: str):
         ...
         return []

 class OpenCodeHarness(Harness):
-    _SANDBOX_CONFIG_DIR = "/sandbox/.config/opencode"
+    _PODMAN_CONFIG_DIR = "/home/agent-ci/.config/opencode"
+    _OPENSHELL_CONFIG_DIR = "/sandbox/.config/opencode"

-    def sandbox_config_mounts(self, config_dir):
+    def sandbox_config_mounts(self, config_dir, runtime: str):
         host_path = os.path.join(config_dir, ".config", "opencode", "opencode.json")
         if os.path.exists(host_path):
-            return [(host_path, f"{self._SANDBOX_CONFIG_DIR}/opencode.json")]
+            target_dir = (
+                self._OPENSHELL_CONFIG_DIR if runtime == "openshell" else self._PODMAN_CONFIG_DIR
+            )
+            return [(host_path, f"{target_dir}/opencode.json")]
         return []
# src/agentic_ci/backends/podman.py
-            for host_path, container_path in self.harness.sandbox_config_mounts(self._config_dir):
+            for host_path, container_path in self.harness.sandbox_config_mounts(
+                self._config_dir, runtime="podman"
+            ):
                 vols.extend(["-v", f"{host_path}:{container_path}:ro,z"])
# src/agentic_ci/backends/openshell/__init__.py
-        for host_path, container_path in self.harness.sandbox_config_mounts(config_dir):
+        for host_path, container_path in self.harness.sandbox_config_mounts(
+            config_dir, runtime="openshell"
+        ):
             ...
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/agentic_ci/harness.py` around lines 383 - 398, The _SANDBOX_CONFIG_DIR
constant hardcodes the path as /sandbox/.config/opencode, but the Podman
container contract specifies that OpenCode config should be mounted to
/home/agent-ci/.config/opencode (matching the OPENCODE_CONFIG_DIR environment
variable in Podman). Update the _SANDBOX_CONFIG_DIR constant value to
/home/agent-ci/.config/opencode so that the sandbox_config_mounts method returns
the correct destination path when mounting the opencode.json file to the
container.

@EmilienM EmilienM 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.

Nice work on the OTel support for OpenCode. The implementation looks clean and the docs are a good addition.

One gap: the new write_sandbox_config() and sandbox_config_mounts() methods on OpenCodeHarness don't have any test coverage. These are doing real work (writing JSON config files, returning mount paths) and are the core mechanism for how OpenCode enables OTel, so they should have tests.

Specifically, I'd expect to see:

  • write_sandbox_config with otel_enabled=True: verify it writes opencode.json with experimental.openTelemetry: true
  • write_sandbox_config with otel_enabled=False: verify it writes the config without the experimental block
  • sandbox_config_mounts: verify it returns the correct (host_path, container_path) tuple when the config file exists, and an empty list when it doesn't

Also worth adding a test_build_env_script_lines_with_otel for OpenCode (Claude Code already has one at line 145) to verify the OTEL env vars show up in the env script when otel_port is set.

@Necmttn

Necmttn commented Jun 22, 2026

Copy link
Copy Markdown

Because OpenCode can drop spans on exit, I would add a validation check against local session artifacts, not just collector receipt.

For one run, compare expected agent turns/tool calls from OpenCode session output against exported OTLP spans/logs: session id, trace id, span count, missing tool spans, usage fields present, flush mode, and process exit timing. That turns the upstream flush caveat into a measurable known-loss report.

Generated with ax - https://github.com/Necmttn/ax

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.

3 participants