feat(otel): OpenTelemetry OTEL export support — independent of anonymous telemetry opt-out - #1510
MaxMoldmann wants to merge 3 commits into
Conversation
Adds a new `otel` module to `jcode-telemetry-core` that exports OpenTelemetry spans to a configurable OTLP collector endpoint. This is a completely separate, independent enterprise feature — it does not affect or depend on the existing anonymous usage telemetry. Features: - Session spans (`jcode.session`) emitted on session end with token counts, turn counts, tool call counts, duration, provider, model, and end reason - Turn spans (`jcode.turn`) emitted per user turn, linked to the session span as parent — forming a parent-child trace tree in the collector - Supports `http/json` (default) and `http/protobuf` wire formats; `grpc` warns and falls back to `http/json` - Transport: hand-rolled HTTP/1.1 POST over stdlib `TcpStream` — safe from both sync and async (tokio) calling contexts, zero new dependencies - Standard OTLP env var configuration: - `OTEL_EXPORTER_OTLP_ENDPOINT` / `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` - `OTEL_EXPORTER_OTLP_PROTOCOL` (http/json or http/protobuf) - `OTEL_EXPORTER_OTLP_HEADERS` (e.g. Authorization=Basic ...) - `OTEL_EXPORTER_OTLP_TIMEOUT`, `OTEL_SERVICE_NAME`, `OTEL_SERVICE_VERSION` - `OTEL_RESOURCE_ATTRIBUTES`, `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` - Privacy: spans never contain prompt text, file contents, tool inputs/outputs, or any free-form user data — only numeric metrics, categorical labels, and UUIDs - Complete no-op when no endpoint env var is configured
Adds 64 tests across unit, integration-boundary, and field-verification levels: Unit tests: - Proto encoding: varint, length-delimited, fixed64, nested messages - JSON encoding: export request structure, resource attributes, header parsing - Span field correctness: trace/span ID derivation, timestamps, attribute values - Protocol selection: http/json vs http/protobuf routing, grpc fallback - URL resolution: OTLP_TRACES_ENDPOINT takes priority over base URL + suffix - Privacy: session_span_attributes_never_contain_arbitrary_string_content (fuzzes arbitrary strings through the span builder; panics if content leaks) - Edge cases: zero-token spans, parent session IDs, missing env vars HTTP integration-boundary tests (real TCP listener): - export_session_span_public_api_proto_sends_valid_otlp - export_turn_span_public_api_json_sends_valid_otlp - http_json_post_sends_correct_content_type_and_json_body - http_proto_post_sends_correct_content_type_and_binary_body - grpc_protocol_falls_back_to_http_json_at_tcp_layer - http_400_response_returns_false - post_otlp_works_from_within_tokio_runtime (proves no panic/deadlock) - uuid_span_ids_survive_proto_tcp_round_trip All tests are #[cfg(test)]-only with zero production build impact.
07edc88 to
8340b48
Compare
|
| jcode-base = { path = "../jcode-base", default-features = false } | ||
| jcode-message-types = { path = "../jcode-message-types" } | ||
| jcode-provider-copilot = { path = "../jcode-provider-copilot" } | ||
| jcode-provider-core = { path = "../jcode-provider-core" } |
There was a problem hiding this comment.
Copilot runtime cannot compile
This removes jcode-provider-openai, but non-test Copilot runtime code still calls that crate to build requests and process responses. Those references cannot resolve, so the Copilot runtime cannot compile. Restore the dependency or replace the calls before merging.
Artifacts
Authored Copilot dependency compilation check
- The executed script attempts a locked package check and compiles the actual source expression with and without the direct crate binding, preserving the tracked lockfile.
Baseline and candidate dependency with non-test source references
- A command captured the baseline manifest, candidate manifest and referenced source lines, showing the dependency was removed while its uses remain.
Locked Copilot package check blocked before compilation
- The targeted offline Cargo check exited 101 because Cargo.lock needs updating under --locked, so it could not reach package compilation.
Source expression compiles with OpenAI crate supplied
- Rustc compiled the source expression with an OpenAI crate binding and exited 0, establishing the comparison baseline.
Source expression fails without OpenAI crate supplied
- Rustc compiled the same expression without the crate binding and exited 1 with E0433, confirming the missing-crate defect.
Ran code and verified through T-Rex
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/jcode-provider-copilot-runtime/Cargo.toml
Line: 22
Comment:
**Copilot runtime cannot compile**
This removes `jcode-provider-openai`, but non-test Copilot runtime code still calls that crate to build requests and process responses. Those references cannot resolve, so the Copilot runtime cannot compile. Restore the dependency or replace the calls before merging.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
Comments Outside DiffThese findings sit on lines the diff does not cover, so they could not be posted inline. Each one leaves this list once its file changes.
|
8340b48 to
0dcb1c2
Compare
| let now = Instant::now(); | ||
| if let Some(ref mut state) = *guard { | ||
| if let Some(ref id) = id_opt { | ||
| finalize_current_turn(id, state, now, reason.as_str(), DeliveryMode::Background); |
There was a problem hiding this comment.
Opt-out sends anonymous telemetry
When anonymous telemetry is opted out but an OTLP collector is configured, ending a session with a pending turn calls finalize_current_turn. It queues an anonymous turn_end payload for delivery to Jcode’s analytics backend before exporting the OTLP span. The user’s opt-out does not prevent that payload from entering the delivery path; separate OTLP finalization from anonymous event emission before merging.
Artifacts
Opted-out pending-turn test source
- The authored test ends an opted-out session with a pending turn and captures both OTLP spans and anonymous lifecycle payloads.
Opted-out turn comparison command
- The authored command runs the same focused test with the prior early return and with the current lifecycle branch.
- The comparison received a session span and captured no lifecycle analytics event.
- The current branch received session and turn spans and captured a turn_end analytics payload with Background delivery.
Loopback telemetry export test source
- The authored Rust integration test starts an opted-out pending-turn session, captures real OTLP requests, and asserts that a child does not end after its parent.
- The authored command runner executes the focused Cargo test and records its command, working directory, exit code, and output.
Collector output with turn finalization omitted
- The control run omitted only the line-23 finalization call and received one parent session span with no child turn span.
Collector output with current line-23 finalization
- The current-code run received linked parent and child spans and failed because the child ended 474,087 ns after the parent.
Ran code and verified through T-Rex
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/jcode-telemetry-core/src/lifecycle.rs
Line: 23
Comment:
**Opt-out sends anonymous telemetry**
When anonymous telemetry is opted out but an OTLP collector is configured, ending a session with a pending turn calls `finalize_current_turn`. It queues an anonymous `turn_end` payload for delivery to Jcode’s analytics backend before exporting the OTLP span. The user’s opt-out does not prevent that payload from entering the delivery path; separate OTLP finalization from anonymous event emission before merging.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| #[cfg(not(test))] | ||
| { | ||
| let cfg = cfg.clone(); | ||
| std::thread::spawn(move || { let _ = post_otlp(&cfg, payload); }); | ||
| } |
There was a problem hiding this comment.
When the process exits immediately after ending a session, this detached export thread is not joined or flushed. The collector can receive neither the session span nor a pending final turn span. Provide a bounded shutdown flush so ending a session does not silently lose its trace.
Artifacts
- This source calls the production session APIs and optionally remains alive for 250 ms, allowing the two exit conditions to be compared.
Loopback collector and child-process command
- This script compiles the Rust child, runs each trial against a local OTLP collector, and records whether a session span arrived.
Collector output with a 250 ms exit grace period
- The executed control command exited successfully and received a `jcode.session` POST in all 8 trials, showing the export can reach the collector.
Collector output after immediate process exit
- The executed immediate-exit command reported successful child exits but received no request in 30 trials, confirming the session-end export was lost.
Ran code and verified through T-Rex
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/jcode-telemetry-core/src/otel.rs
Line: 908-912
Comment:
**Exit can discard spans**
When the process exits immediately after ending a session, this detached export thread is not joined or flushed. The collector can receive neither the session span nor a pending final turn span. Provide a bounded shutdown flush so ending a session does not silently lose its trace.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| #[cfg(not(test))] | ||
| { | ||
| let cfg = cfg.clone(); | ||
| std::thread::spawn(move || { let _ = post_otlp(&cfg, payload); }); | ||
| } |
There was a problem hiding this comment.
Stalled exports accumulate threads
Each turn starts a separate export thread without a concurrency limit. While a collector withheld responses, 24 turn exports left 24 requests in flight and the process at 25 threads. Continued turns during a collector stall can accumulate threads and consume resources; bounding export concurrency would avoid this non-blocking operational cost.
Artifacts
Rust caller for 24 turn exports
- The authored caller invoked the real non-test turn-export API 24 times and stayed alive for inspection, enabling the thread comparison.
Stalled loopback collector and Cargo artifact harness
- The executed harness selected each revision’s Cargo-produced library, withheld HTTP responses, and sampled the caller’s threads, ensuring the comparison used the intended implementations.
Pre-change stalled-collector run
- The pre-change run observed two process threads and one parsed request while responses were withheld, showing the synchronous baseline.
Current-code stalled-collector run
- The HEAD run observed 25 process threads and 24 parsed requests before any response, confirming concurrent exporter-thread accumulation in this trial.
Ran code and verified through T-Rex
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/jcode-telemetry-core/src/otel.rs
Line: 931-935
Comment:
**Stalled exports accumulate threads**
Each turn starts a separate export thread without a concurrency limit. While a collector withheld responses, 24 turn exports left 24 requests in flight and the process at 25 threads. Continued turns during a collector stall can accumulate threads and consume resources; bounding export concurrency would avoid this non-blocking operational cost.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| let now = Instant::now(); | ||
| if let Some(ref mut state) = *guard { | ||
| if let Some(ref id) = id_opt { | ||
| finalize_current_turn(id, state, now, reason.as_str(), DeliveryMode::Background); |
There was a problem hiding this comment.
For an opted-out session with a pending turn, session export captures the parent span’s end time before this call finalizes the turn. The exported child then ends after its parent, producing inconsistent trace durations. Finalize the turn before timestamping the session; this measurement correction is non-blocking.
Artifacts
Opted-out pending-turn test source
- The authored test ends an opted-out session with a pending turn and captures both OTLP spans and anonymous lifecycle payloads.
Opted-out turn comparison command
- The authored command runs the same focused test with the prior early return and with the current lifecycle branch.
- The comparison received a session span and captured no lifecycle analytics event.
- The current branch received session and turn spans and captured a turn_end analytics payload with Background delivery.
Loopback telemetry export test source
- The authored Rust integration test starts an opted-out pending-turn session, captures real OTLP requests, and asserts that a child does not end after its parent.
- The authored command runner executes the focused Cargo test and records its command, working directory, exit code, and output.
Collector output with turn finalization omitted
- The control run omitted only the line-23 finalization call and received one parent session span with no child turn span.
Collector output with current line-23 finalization
- The current-code run received linked parent and child spans and failed because the child ended 474,087 ns after the parent.
Ran code and verified through T-Rex
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/jcode-telemetry-core/src/lifecycle.rs
Line: 23
Comment:
**Final turn outlives session**
For an opted-out session with a pending turn, session export captures the parent span’s end time before this call finalizes the turn. The exported child then ends after its parent, producing inconsistent trace durations. Finalize the turn before timestamping the session; this measurement correction is non-blocking.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| let addrs: Vec<_> = match addr_str.to_socket_addrs() { | ||
| Ok(iter) => iter.collect(), | ||
| Err(e) => { | ||
| logging::warn(&format!("otel: DNS resolve of {addr_str} failed: {e}")); | ||
| return false; | ||
| } | ||
| }; | ||
| let timeout = cfg.timeout; | ||
| let mut stream_opt = None; | ||
| for addr in &addrs { | ||
| match TcpStream::connect_timeout(addr, timeout) { |
There was a problem hiding this comment.
Hostname exports exceed timeout
If hostname resolution is slow, it has no deadline tied to OTEL_EXPORTER_OTLP_TIMEOUT; each resolved address also receives a fresh full connection timeout. With a delayed lookup, an export completed after 250 ms despite a 50 ms setting. Slow or multi-address hostnames can therefore keep export threads occupied beyond the requested timeout, a non-blocking operational concern.
Artifacts
Delayed DNS reproduction script
- The script extracts and runs each revision’s actual `post_otlp` implementation against a loopback collector with a controlled DNS delay, making the timeout comparison reproducible.
Before-change export with delayed DNS
- The parent revision returned false after 275 ms with a 50 ms timeout, and the collector received no request or HTTP response.
After-change export with delayed DNS
- The changed revision sent the request and received HTTP/1.1 200 OK after 250 ms despite the 50 ms configured timeout.
Ran code and verified through T-Rex
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/jcode-telemetry-core/src/otel.rs
Line: 779-789
Comment:
**Hostname exports exceed timeout**
If hostname resolution is slow, it has no deadline tied to `OTEL_EXPORTER_OTLP_TIMEOUT`; each resolved address also receives a fresh full connection timeout. With a delayed lookup, an export completed after 250 ms despite a 50 ms setting. Slow or multi-address hostnames can therefore keep export threads occupied beyond the requested timeout, a non-blocking operational concern.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
Summary