Skip to content

feat(tracing): add OpenTelemetry traces with gen_ai.* and tool spans#20

Merged
digitalfiz merged 4 commits intomainfrom
feat/otel-llm-spans
Mar 26, 2026
Merged

feat(tracing): add OpenTelemetry traces with gen_ai.* and tool spans#20
digitalfiz merged 4 commits intomainfrom
feat/otel-llm-spans

Conversation

@digitalfiz
Copy link
Copy Markdown
Contributor

@digitalfiz digitalfiz commented Mar 24, 2026

Closes #19

What this adds

Full OTel trace instrumentation across session, LLM message, and tool lifecycles, exported via OTLP/gRPC alongside the existing metrics and logs.

Trace structure

opencode.session (root span)
  └─ gen_ai.chat (SpanKind.CLIENT — per assistant message)
  └─ opencode.tool <name> (SpanKind.INTERNAL — per tool call)
  └─ opencode.tool <name>
  ...

Subagent sessions (parentID present) are nested under their parent session span.

Spans and attributes

opencode.session

  • session.id, session.is_subagent, project.id
  • On idle: session.total_tokens, session.total_cost_usd, session.total_messages
  • Status: OK on idle, ERROR on session.error

gen_ai.chat (OpenLLMetry compatible)

  • gen_ai.system (provider), gen_ai.request.model, session.id
  • On completion: gen_ai.usage.input_tokens, gen_ai.usage.output_tokens, gen_ai.usage.reasoning_tokens, gen_ai.usage.cache_read_tokens, gen_ai.usage.cache_creation_tokens, gen_ai.response.finish_reason, cost_usd, duration_ms
  • Status: OK or ERROR

opencode.tool <name>

  • tool.name, session.id
  • On success: tool.result_size_bytes
  • On error: tool.error
  • Status: OK or ERROR

Edge cases handled

Edge case Handling
Orphaned tool spans (session ends before tool completes) Swept with ERROR status in sweepSession on session.idle and session.error
Orphaned message spans (session ends before message completes) Same sweep, ERROR status
Out-of-order tool events (completed arrives without prior running) Best-effort span started and immediately ended
Subagent with missing parent span Falls back to a root span, no error
session.error with undefined sessionID Session span end skipped safely

Known limitations (documented in #19)

  • Prompt/completion content is not available in spans — the plugin API does not expose message bodies
  • Span context cannot be injected into the outbound LLM HTTP call — requires native core instrumentation

Changes

File Change
package.json Add @opentelemetry/sdk-trace-base, @opentelemetry/exporter-trace-otlp-grpc
src/otel.ts BasicTracerProvider + BatchSpanProcessor + OTLPTraceExporter; setupOtel returns tracerProvider
src/types.ts PendingToolSpan gains span: Span; HandlerContext gains tracer, sessionSpans, messageSpans
src/index.ts Wires tracer and span maps into context; calls startMessageSpan on incomplete message.updated; includes tracerProvider.shutdown()
src/handlers/session.ts Session span lifecycle; subagent parent linking; orphan sweep
src/handlers/message.ts startMessageSpan() export; LLM span lifecycle with gen_ai.* attrs; tool span lifecycle; out-of-order handling
tests/helpers.ts SpySpan, SpyTracer, makeTracer() spy infrastructure
tests/handlers/spans.test.ts 34 new tests covering all span behaviours and edge cases

185 tests pass, 0 type errors.

Summary by CodeRabbit

  • New Features

    • Added distributed tracing for sessions, assistant messages, and tool calls with lifecycle tracking and cleanup.
    • New config option to disable specific trace types at runtime.
  • Chores

    • Added OpenTelemetry tracing packages to dependencies.
  • Tests

    • Added comprehensive tests and test helpers to validate tracing behavior, lifecycle, out-of-order handling, and disablement modes.

Adds a TracerProvider backed by OTLP/gRPC and implements full trace
instrumentation across session, LLM message, and tool lifecycles.

- session.created starts an opencode.session root span; parentID is
  used to nest subagent sessions under their parent session span
- message.updated starts a gen_ai.chat span (SpanKind.CLIENT) with
  gen_ai.system / gen_ai.request.model attributes; on completion the
  span is annotated with gen_ai.usage.* token counts, cost, and
  finish_reason then ended with OK or ERROR status
- message.part.updated (tool, running) starts an opencode.tool <name>
  child span under the session span; completed/error ends it with the
  appropriate status and result size or error message
- Out-of-order tool events (completed without prior running) are handled
  gracefully with a best-effort span started and immediately ended
- sweepSession now ends any orphaned tool and message spans with ERROR
  status on session.idle and session.error, addressing the orphaned span
  edge case documented in issue #19
- TracerProvider.shutdown() included in the SIGTERM/SIGINT/beforeExit
  shutdown sequence

Adds 34 new span tests; all 185 tests pass.

Closes #19
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 24, 2026

📝 Walkthrough

Walkthrough

Adds OpenTelemetry tracing: tracer provider setup, tracer injection into HandlerContext, and creation, lifecycle management, and cleanup of session, message (LLM), and tool spans with config gating to disable specific trace categories.

Changes

Cohort / File(s) Summary
Dependencies
package.json
Added @opentelemetry/exporter-trace-otlp-grpc and @opentelemetry/sdk-trace-base.
OTEL provider setup
src/otel.ts
Create BasicTracerProvider, attach BatchSpanProcessor + OTLPTraceExporter, register global tracer provider; setupOtel now returns tracerProvider.
Bootstrap & context wiring
src/index.ts
Create tracer via trace.getTracer, add tracer, sessionSpans, messageSpans, and disabledTraces into HandlerContext; call tracerProvider.shutdown() on plugin shutdown; conditionally start message spans before message.updated handling.
Session spans & cleanup
src/handlers/session.ts
Start opencode.session spans on create, end with OK/ERROR on idle/error; sweepSession ends and marks orphaned pending tool/message spans as ERROR and removes them.
Message & tool spans
src/handlers/message.ts
Exported startMessageSpan; handleMessagePartUpdated starts/ends opencode.tool spans (handles out-of-order cases); handleMessageUpdated records usage, sets span status, ends gen_ai.chat message spans, and removes them from context.
Types & util
src/types.ts, src/util.ts
Added Span/Tracer imports; HandlerContext extended with tracer, tracePrefix, disabledTraces, sessionSpans, messageSpans; PendingToolSpan may include span; added isTraceEnabled(name, ctx) helper.
Config parsing
src/config.ts
Add disabledTraces: Set<string> to PluginConfig; parse OPENCODE_DISABLE_TRACES into the set.
Tests & test helpers
tests/helpers.ts, tests/handlers/*.test.ts, tests/config.test.ts, tests/util.test.ts
Added SpyTracer/SpySpan and makeTracer(); updated makeCtx() to accept disabledTraces and include tracer/span maps; added comprehensive spans tests for session/tool/message lifecycles, orphan cleanup, and gating; updated related assertions.

Sequence Diagram

sequenceDiagram
    participant Plugin
    participant Tracer
    participant SessionSpan
    participant MessageSpan
    participant ToolSpan
    participant OTLPExporter

    Plugin->>Tracer: setupOtel() -> BasicTracerProvider + BatchSpanProcessor
    Plugin->>Tracer: getTracer("com.opencode") -> tracer

    Plugin->>SessionSpan: handleSessionCreated -> start "opencode.session"
    SessionSpan->>Plugin: stored in ctx.sessionSpans

    Plugin->>MessageSpan: startMessageSpan (assistant message)
    MessageSpan->>SessionSpan: set parent (if session span exists)
    MessageSpan->>Plugin: stored in ctx.messageSpans

    Plugin->>ToolSpan: handleMessagePartUpdated (tool running) -> start "opencode.tool <name>"
    ToolSpan->>SessionSpan: set parent (if session span exists)
    ToolSpan->>Plugin: stored in ctx.pendingToolSpans

    Plugin->>ToolSpan: handleMessagePartUpdated (completed/error) -> set attrs/status, end, remove pending
    Plugin->>MessageSpan: handleMessageUpdated (completed/error) -> set gen_ai.* attrs, set status, end, remove message span

    Plugin->>SessionSpan: handleSessionIdle / handleSessionError -> set final attrs/status, end, remove session span
    SessionSpan->>OTLPExporter: BatchSpanProcessor exports spans
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 I hopped in with tracer in paw,

Spans for sessions, tools, and awe,
Tokens tallied, errors told,
Little spans in ribbons of gold —
Hooray, the traces weave a trail!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 43.48% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately summarizes the main change: adding OpenTelemetry traces with gen_ai.* and tool spans, which is the primary focus of this comprehensive tracing implementation.
Linked Issues check ✅ Passed The PR fully implements the requirements from issue #19: OpenTelemetry trace instrumentation with gen_ai.* spans, tool spans, session lifecycle tracing, subagent nesting, and observable attributes for usage/cost/errors.
Out of Scope Changes check ✅ Passed All changes are directly related to implementing OpenTelemetry tracing: span creation, attributes, exports, and test coverage. No unrelated modifications detected.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/otel-llm-spans

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.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (4)
src/handlers/session.ts (1)

58-65: Consider adding sessionID to messageSpan key for cleaner lookup.

The cast (span as unknown as { attributes?: ... }).attributes to access internal span attributes is implementation-dependent. If messageSpans were keyed by sessionID:messageID instead of just messageID, you could match entries by key prefix without introspecting span internals.

Suggested approach

In startMessageSpan, key by ${sessionID}:${messageID}:

- setBoundedMap(ctx.messageSpans, messageID, msgSpan)
+ setBoundedMap(ctx.messageSpans, `${sessionID}:${messageID}`, msgSpan)

Then in sweepSession:

- for (const [msgID, span] of ctx.messageSpans) {
-   const attrs = (span as unknown as { attributes?: Record<string, unknown> }).attributes
-   if (attrs?.["session.id"] === sessionID) {
+ for (const [key, span] of ctx.messageSpans) {
+   if (key.startsWith(`${sessionID}:`)) {

And update handleMessageUpdated similarly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/handlers/session.ts` around lines 58 - 65, The current sweepSession logic
inspects span internals to find spans for a session; instead, change
messageSpans to use keys that include sessionID (e.g.,
`${sessionID}:${messageID}`) so lookups can be done by key prefix without
casting into span attributes. Update startMessageSpan to insert entries under
the combined key, update sweepSession to iterate and match keys starting with
`${sessionID}:` to set span status/end and delete, and update
handleMessageUpdated to use the same composite key when accessing messageSpans.
tests/handlers/spans.test.ts (3)

58-75: Check the exact tool.result_size_bytes value.

typeof === "number" is too weak here; a constant 0 or character-count bug would still pass. Let the fixture override output and assert the exact byte length from a known payload, ideally with a multibyte string so bytes vs. characters cannot be conflated.

Also applies to: 200-205

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/handlers/spans.test.ts` around lines 58 - 75, The test's assertion that
tool.result_size_bytes is merely a number is too weak; update the fixture maker
makeToolPartUpdated to allow overriding the output and in the test(s) (including
the other occurrence around the 200-205 area) set a known multibyte string as
output, compute its byte length (not character count) and assert
tool.result_size_bytes equals that exact byte length; locate the helper function
makeToolPartUpdated and the tests that consume it, pass an explicit
overrides.output (multibyte payload) and assert the precise byte-length value
rather than typeof checks.

304-309: Strengthen the "no-op" test with state assertions.

Right now this only proves the handler does not throw. It would still pass if handleMessageUpdated created/ended a span or mutated ctx.messageSpans, so add explicit assertions that tracer state stays unchanged.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/handlers/spans.test.ts` around lines 304 - 309, Update the
"handleMessageUpdated no-ops span handling when no span exists for messageID"
test to assert tracer state doesn't change: capture the initial ctx.messageSpans
(e.g., a shallow copy or size) before calling handleMessageUpdated, call
handleMessageUpdated(makeAssistantMessageUpdated({ id: "msg_no_span" }), ctx),
then assert that ctx.messageSpans remains identical (same keys/size and no new
entries) and that no spans were started/ended on the tracer; use existing
helpers makeCtx, handleMessageUpdated, and makeAssistantMessageUpdated to locate
the test and assert against ctx.messageSpans after the call.

26-28: Let the session-error fixture omit sessionID.

This helper forces a string sessionID, which means the suite cannot cover the documented session.error no-op path when sessionID is missing. Make it optional and add one regression test for that case.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/handlers/spans.test.ts` around lines 26 - 28, The helper
makeSessionError currently requires a string sessionID which prevents testing
the documented no-op path; update makeSessionError(sessionID?: string, error?: {
name: string }): EventSessionError to accept an optional sessionID and construct
the returned properties object so it omits sessionID when undefined (e.g., only
include sessionID key if provided), then add a regression test in
tests/handlers/spans.test.ts that calls makeSessionError() (or with undefined)
and asserts the handler follows the no-op path for session.error; locate and
update the makeSessionError function and add a new test case exercising the
missing-sessionID behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tests/handlers/spans.test.ts`:
- Around line 150-156: The tests currently only check that a second span exists
but not that it is a child of the first; update the spy tracer used by makeCtx
to record parent linkage (e.g., store parentId or parentSpan reference when
startSpan is called) and then in these tests (the one using makeCtx +
handleSessionCreated and the other similar cases at lines ~229-235 and ~311-317)
assert the child span's parent matches the parent span (for example assert
tracer.spans[1].parentId === tracer.spans[0].id or tracer.spans[1].parent ===
tracer.spans[0]); keep using makeCtx, tracer, and handleSessionCreated to locate
the tests and the tracer implementation to add the parent tracking.

---

Nitpick comments:
In `@src/handlers/session.ts`:
- Around line 58-65: The current sweepSession logic inspects span internals to
find spans for a session; instead, change messageSpans to use keys that include
sessionID (e.g., `${sessionID}:${messageID}`) so lookups can be done by key
prefix without casting into span attributes. Update startMessageSpan to insert
entries under the combined key, update sweepSession to iterate and match keys
starting with `${sessionID}:` to set span status/end and delete, and update
handleMessageUpdated to use the same composite key when accessing messageSpans.

In `@tests/handlers/spans.test.ts`:
- Around line 58-75: The test's assertion that tool.result_size_bytes is merely
a number is too weak; update the fixture maker makeToolPartUpdated to allow
overriding the output and in the test(s) (including the other occurrence around
the 200-205 area) set a known multibyte string as output, compute its byte
length (not character count) and assert tool.result_size_bytes equals that exact
byte length; locate the helper function makeToolPartUpdated and the tests that
consume it, pass an explicit overrides.output (multibyte payload) and assert the
precise byte-length value rather than typeof checks.
- Around line 304-309: Update the "handleMessageUpdated no-ops span handling
when no span exists for messageID" test to assert tracer state doesn't change:
capture the initial ctx.messageSpans (e.g., a shallow copy or size) before
calling handleMessageUpdated, call
handleMessageUpdated(makeAssistantMessageUpdated({ id: "msg_no_span" }), ctx),
then assert that ctx.messageSpans remains identical (same keys/size and no new
entries) and that no spans were started/ended on the tracer; use existing
helpers makeCtx, handleMessageUpdated, and makeAssistantMessageUpdated to locate
the test and assert against ctx.messageSpans after the call.
- Around line 26-28: The helper makeSessionError currently requires a string
sessionID which prevents testing the documented no-op path; update
makeSessionError(sessionID?: string, error?: { name: string }):
EventSessionError to accept an optional sessionID and construct the returned
properties object so it omits sessionID when undefined (e.g., only include
sessionID key if provided), then add a regression test in
tests/handlers/spans.test.ts that calls makeSessionError() (or with undefined)
and asserts the handler follows the no-op path for session.error; locate and
update the makeSessionError function and add a new test case exercising the
missing-sessionID behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e8b0e390-f184-4920-9d12-1470b08c86bc

📥 Commits

Reviewing files that changed from the base of the PR and between f578d23 and 6c848a7.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (9)
  • package.json
  • src/handlers/message.ts
  • src/handlers/session.ts
  • src/index.ts
  • src/otel.ts
  • src/types.ts
  • tests/handlers/session.test.ts
  • tests/handlers/spans.test.ts
  • tests/helpers.ts

Introduces OPENCODE_DISABLE_TRACES, a comma-separated env var accepting
'session', 'llm', and 'tool' to independently suppress each trace type.
Mirrors the existing OPENCODE_DISABLE_METRICS pattern.

- PendingToolSpan.span is now optional so the pendingToolSpans entry
  (needed for histogram timing) is still created when tool traces are
  disabled; span creation is skipped
- isTraceEnabled() added to util.ts as a direct parallel of
  isMetricEnabled()
- session.ts gates opencode.session span creation behind
  isTraceEnabled('session'); a prominent comment documents that
  disabling 'session' while 'llm' or 'tool' remain enabled will cause
  those spans to emit as unlinked root spans with no parent
- message.ts gates gen_ai.chat spans behind isTraceEnabled('llm') and
  tool spans (both running and completion paths) behind
  isTraceEnabled('tool'); metrics and logs are unaffected in all cases
- Startup log emitted when any traces are disabled

Adds 32 new tests (config parsing, isTraceEnabled, per-type disable
behaviour); all 217 tests pass.
- SpyTracer now captures parent span linkage by extracting the active
  span from the Context passed to startSpan; SpySpan gains a parentSpan
  field enabling direct parent/child assertions in tests
- Assert tracer.spans[n].parentSpan === tracer.spans[0] in the subagent
  session, tool-parenting, and message-parenting tests — previously
  these only verified a second span existed, not that it was actually
  nested under the correct parent
- messageSpans re-keyed from messageID to sessionID:messageID; sweepSession
  now matches entries by key prefix (sessionID:) rather than casting into
  span attributes; startMessageSpan and handleMessageUpdated updated to
  use the composite key
- tool.result_size_bytes assertion uses a known multibyte Japanese string
  with Buffer.byteLength for exact byte-vs-character correctness; same
  fix applied to tool_result_size_bytes log assertion in message.test.ts
- handleMessageUpdated no-op test asserts tracer.spans length and
  ctx.messageSpans size are unchanged after the call
- makeSessionError accepts optional sessionID; new test asserts that
  session.error with undefined sessionID does not end any session span
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/handlers/message.ts`:
- Around line 215-216: The span name currently built as `opencode.tool
${toolPart.tool}` is wrong; update both places where ctx.tracer.startSpan is
called so they use the documented dotted schema `opencode.tool.<name>` (e.g.,
construct the span name by joining "opencode.tool" and toolPart.tool with a dot)
so both creation paths emit identical span names; locate the calls to
ctx.tracer.startSpan that reference toolPart.tool and change the string
interpolation to produce "opencode.tool.<toolName>" in both branches.
- Around line 259-271: The fallback span creation for tools currently uses
pending?.span or starts a new root span via ctx.tracer.startSpan, which loses
the session parent when the session span exists; modify the fallback logic in
the isTraceEnabled("tool", ctx) block so that when pending?.span is missing you
pass the existing session span as the parent to ctx.tracer.startSpan (e.g., use
pending?.span ?? ctx.sessionSpan (or pending?.sessionSpan) as the parent option)
so toolSpan remains a child of the session span while preserving existing
attributes like toolPart.sessionID and ctx.commonAttrs.

In `@src/handlers/session.ts`:
- Around line 64-70: The sweep currently infers ownership by reading
span.attributes instead of using explicit ownership; update the messageSpans map
to store an object that includes sessionID alongside the span (e.g., store {
sessionID, span } for each msgID when entries are created) and change the
cleanup loop in the session end/error handler to compare the stored
entry.sessionID to the local sessionID before calling span.setStatus(...) and
span.end() and deleting the entry; update any code that adds to ctx.messageSpans
to populate the new structure so ownership is always tracked directly.

In `@src/util.ts`:
- Around line 33-39: isTraceEnabled currently only checks ctx.disabledTraces and
ignores the global telemetry flag from loadConfig (OPENCODE_ENABLE_TELEMETRY),
so traces can be enabled even when telemetry is globally disabled; fix by making
the gate also respect that flag—accept or propagate a boolean (e.g., ctx.enabled
or a separate enabled param populated from loadConfig) and change the predicate
in isTraceEnabled to return true only when the global enabled flag is true and
the trace name is not in ctx.disabledTraces (e.g., use ctx.enabled &&
!ctx.disabledTraces.has(name)), updating callers that invoke isTraceEnabled to
pass the global enabled value.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4a5ef305-a6d9-44e6-81fc-ee41c3529562

📥 Commits

Reviewing files that changed from the base of the PR and between 6c848a7 and 89cb9b9.

📒 Files selected for processing (10)
  • src/config.ts
  • src/handlers/message.ts
  • src/handlers/session.ts
  • src/index.ts
  • src/types.ts
  • src/util.ts
  • tests/config.test.ts
  • tests/handlers/spans.test.ts
  • tests/helpers.ts
  • tests/util.test.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/index.ts
  • tests/helpers.ts

Comment on lines +33 to +39
/**
* Returns `true` if the trace type is not in the disabled set.
* Valid names are `"session"`, `"llm"`, and `"tool"`.
*/
export function isTraceEnabled(name: string, ctx: { disabledTraces: Set<string> }): boolean {
return !ctx.disabledTraces.has(name)
}
Copy link
Copy Markdown

@coderabbitai coderabbitai bot Mar 24, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Include the global telemetry flag in this gate.

loadConfig() resolves enabled separately from disabledTraces, but this helper ignores it. With OPENCODE_ENABLE_TELEMETRY unset and OPENCODE_DISABLE_TRACES empty, every caller here still evaluates to trace-enabled.

Suggested fix
-export function isTraceEnabled(name: string, ctx: { disabledTraces: Set<string> }): boolean {
-  return !ctx.disabledTraces.has(name)
+export function isTraceEnabled(
+  name: string,
+  ctx: { enabled: boolean; disabledTraces: Set<string> },
+): boolean {
+  return ctx.enabled && !ctx.disabledTraces.has(name)
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/util.ts` around lines 33 - 39, isTraceEnabled currently only checks
ctx.disabledTraces and ignores the global telemetry flag from loadConfig
(OPENCODE_ENABLE_TELEMETRY), so traces can be enabled even when telemetry is
globally disabled; fix by making the gate also respect that flag—accept or
propagate a boolean (e.g., ctx.enabled or a separate enabled param populated
from loadConfig) and change the predicate in isTraceEnabled to return true only
when the global enabled flag is true and the trace name is not in
ctx.disabledTraces (e.g., use ctx.enabled && !ctx.disabledTraces.has(name)),
updating callers that invoke isTraceEnabled to pass the global enabled value.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch on the surface, but in practice this invariant is already guaranteed. If OPENCODE_ENABLE_TELEMETRY is unset, config.enabled is false, and the plugin returns an empty {} object immediately at src/index.ts:44. The event handlers are never registered with opencode, so isTraceEnabled can never be called when the global enabled flag is false. Threading enabled through the HandlerContext to double-check it in the utility function would add complexity for an unreachable state.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify the early-return guard in src/index.ts around line 44
sed -n '35,60p' src/index.ts

Length of output: 778


@digitalfiz, confirmed — the early-return guard at src/index.ts Line 43 is clear:

if (!config.enabled) {
    await log("info", "telemetry disabled (set OPENCODE_ENABLE_TELEMETRY to enable)")
    return {}
}

Since the plugin returns {} before any event handlers are registered, isTraceEnabled is structurally unreachable when OPENCODE_ENABLE_TELEMETRY is unset. Your reasoning is correct — adding an enabled field to HandlerContext just to guard an unreachable state would be unnecessary complexity. I'll withdraw the concern.


✏️ Learnings added
Learnt from: digitalfiz
Repo: DEVtheOPS/opencode-plugin-otel PR: 20
File: src/util.ts:33-39
Timestamp: 2026-03-24T19:03:03.849Z
Learning: In `src/index.ts`, when `config.enabled` is false (i.e., `OPENCODE_ENABLE_TELEMETRY` is unset), the plugin returns `{}` immediately before registering any event handlers. Therefore, utilities like `isTraceEnabled` and `isMetricEnabled` in `src/util.ts` are structurally unreachable when telemetry is globally disabled — there is no need to thread a global `enabled` flag into `HandlerContext` or these utility functions.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: CR
Repo: DEVtheOPS/opencode-plugin-otel PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-15T00:23:02.156Z
Learning: Applies to **/{otel,handlers}/*.ts : All OTel instrumentation must be gated on `OPENCODE_ENABLE_TELEMETRY` environment variable; the plugin always loads regardless, only telemetry is disabled when unset

Learnt from: CR
Repo: DEVtheOPS/opencode-plugin-otel PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-15T00:23:02.156Z
Learning: Applies to src/config.ts : All environment variables must be prefixed with `OPENCODE_` (e.g., `OPENCODE_ENABLE_TELEMETRY`, `OPENCODE_OTLP_ENDPOINT`, `OPENCODE_OTLP_METRICS_INTERVAL`, `OPENCODE_OTLP_LOGS_INTERVAL`, `OPENCODE_METRIC_PREFIX`, `OPENCODE_OTLP_HEADERS`, `OPENCODE_RESOURCE_ATTRIBUTES`); never use bare `OTEL_*` names for plugin config

…-order parentage

- Applies OPENCODE_METRIC_PREFIX (default 'opencode.') to session and
  tool spans so they respect user configuration, matching the behaviour
  of metrics. gen_ai.chat remains unprefixed as it is an OpenLLMetry
  standard semantic convention.
- Fixes the out-of-order tool fallback path (where completed arrives
  before running) to correctly parent the fallback span to the active
  session span, rather than starting an unlinked root span.
- Fixes a typo in tool span names (was 'opencode.tool bash', is now
  'opencode.tool.bash').
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
src/handlers/message.ts (1)

282-283: Buffer.byteLength is called twice for successful tool output.

When success is true, Buffer.byteLength(output, "utf8") is computed once for the span attribute (line 283) and again for the log attribute (line 294). Consider extracting to a local variable to avoid redundant computation.

Suggested refactor
       toolSpan.setAttribute("tool.success", success)
       if (success) {
         const output = (toolPart.state as { output: string }).output
-        toolSpan.setAttribute("tool.result_size_bytes", Buffer.byteLength(output, "utf8"))
+        const resultSizeBytes = Buffer.byteLength(output, "utf8")
+        toolSpan.setAttribute("tool.result_size_bytes", resultSizeBytes)
         toolSpan.setStatus({ code: SpanStatusCode.OK })
       } else {

Then reuse resultSizeBytes for sizeAttr:

-    const sizeAttr = success
-      ? { tool_result_size_bytes: Buffer.byteLength((toolPart.state as { output: string }).output, "utf8") }
-      : { error: (toolPart.state as { error: string }).error }
+    const sizeAttr = success
+      ? { tool_result_size_bytes: resultSizeBytes }
+      : { error: (toolPart.state as { error: string }).error }

This requires restructuring the block slightly since resultSizeBytes is scoped inside the isTraceEnabled block, but the optimization would eliminate the duplicate computation.

Also applies to: 293-294

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/handlers/message.ts` around lines 282 - 283, The code calls
Buffer.byteLength(output, "utf8") twice for successful tool output; extract the
computed size into a local const (e.g., resultSizeBytes) once after computing
const output = (toolPart.state as { output: string }).output and reuse it when
setting toolSpan.setAttribute("tool.result_size_bytes", ...) and when building
the log attribute (sizeAttr) inside the isTraceEnabled block; ensure
resultSizeBytes is declared in a scope visible to both the span-attribute and
the subsequent logging logic so you remove the duplicate Buffer.byteLength
calls.
src/handlers/session.ts (1)

53-71: Prefix-based matching assumes sessionID contains no colons.

The sweep logic at line 66 uses key.startsWith(msgPrefix) where msgPrefix = \${sessionID}:`. If a sessionID` ever contains a colon character (e.g., a UUID-like format or user-provided ID), this could match unrelated entries or fail to match intended ones.

Given that session IDs typically come from the SDK and are likely UUIDs or similar safe formats, this is low-risk but worth noting. If you have control over ID generation, ensure colons are excluded, or consider a more robust key structure.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/handlers/session.ts` around lines 53 - 71, The sweepSession function is
using prefix matching (msgPrefix = `${sessionID}:`) against ctx.messageSpans
keys which breaks if sessionID can contain ':'; update the sweep to extract the
session portion from each key and compare for exact equality instead of
startsWith (e.g., split the key on ':' and check split[0] === sessionID), or
alternatively change how keys are composed/stored (use a safe delimiter, encode
the sessionID, or store a nested Map keyed first by sessionID) so that
ctx.messageSpans cleanup only removes entries belonging to the exact sessionID;
apply this fix in sweepSession where msgPrefix, ctx.messageSpans,
span.setStatus, and span.end are used.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/handlers/message.ts`:
- Around line 282-283: The code calls Buffer.byteLength(output, "utf8") twice
for successful tool output; extract the computed size into a local const (e.g.,
resultSizeBytes) once after computing const output = (toolPart.state as {
output: string }).output and reuse it when setting
toolSpan.setAttribute("tool.result_size_bytes", ...) and when building the log
attribute (sizeAttr) inside the isTraceEnabled block; ensure resultSizeBytes is
declared in a scope visible to both the span-attribute and the subsequent
logging logic so you remove the duplicate Buffer.byteLength calls.

In `@src/handlers/session.ts`:
- Around line 53-71: The sweepSession function is using prefix matching
(msgPrefix = `${sessionID}:`) against ctx.messageSpans keys which breaks if
sessionID can contain ':'; update the sweep to extract the session portion from
each key and compare for exact equality instead of startsWith (e.g., split the
key on ':' and check split[0] === sessionID), or alternatively change how keys
are composed/stored (use a safe delimiter, encode the sessionID, or store a
nested Map keyed first by sessionID) so that ctx.messageSpans cleanup only
removes entries belonging to the exact sessionID; apply this fix in sweepSession
where msgPrefix, ctx.messageSpans, span.setStatus, and span.end are used.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ea42bc7b-b8fe-4d6f-8b0e-d9d7b2339f97

📥 Commits

Reviewing files that changed from the base of the PR and between 89cb9b9 and 65f1e70.

📒 Files selected for processing (7)
  • src/handlers/message.ts
  • src/handlers/session.ts
  • src/index.ts
  • src/types.ts
  • tests/handlers/message.test.ts
  • tests/handlers/spans.test.ts
  • tests/helpers.ts
✅ Files skipped from review due to trivial changes (2)
  • tests/handlers/message.test.ts
  • tests/handlers/spans.test.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/types.ts
  • tests/helpers.ts

@digitalfiz digitalfiz merged commit 0a00b43 into main Mar 26, 2026
5 checks passed
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.

[Feature]: otel-llm / openllmetry compatible spans

1 participant