feat: export OTLP traces to a local file - #1042
SandyChapman wants to merge 4 commits into
Conversation
Relay's OpenTelemetry plugin could only ship spans to a collector. ATIF and ATOF both write to disk, so a consumer that treats a trajectory as an artifact rather than as telemetry -- an evaluation harness, an offline replay, any environment with no collector to export to -- had no OTLP option at all. `[[components.config.opentelemetry.file_sinks]]` writes the same `ExportTraceServiceRequest` an endpoint would receive. The default `json_lines` format follows the OpenTelemetry Protocol File Exporter specification: one OTLP/JSON record per line. `proto` writes each record length-delimited, matching the Collector file exporter's layout, for consumers that would rather not pay JSON's size and parse cost. A file sink is modelled as a destination rather than as a third `OtlpTransport`: it has no endpoint, headers, or timeout, and endpoint validation does not apply to it. Everything above the exporter -- projection, id generation, batching, resource attributes -- is shared, so the two destination kinds cannot drift on what a span means. `SpanExporter::export` returns `impl Future` and the trait is therefore not dyn-compatible, so dispatch is a concrete enum rather than a boxed trait object. Records are flushed before each export returns, so a run that dies between batches still leaves a readable prefix. Output is created with the same owner-only permissions and directory confinement as the ATOF and ATIF sinks, because a trajectory carries prompt and response content. Signed-off-by: Sandy Chapman <schapman@nvidia.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Sandy Chapman <schapman@nvidia.com>
`nemo-relay configure` lists file sinks beside trace endpoints, and the Python, Node, Go, and FFI surfaces each gain the destination so a programmatically built subscriber can write a trace without a collector. The Node suite caught a second validation site: `validate_opentelemetry_section` runs before activation and still required an endpoint, so a file-sink-only section was rejected before `register_opentelemetry` ever saw it. It now accepts a file sink as a destination and reports a malformed one per index, matching how it already reports endpoints. Headers and `header_env` are refused on a Python file sink rather than silently dropped: a caller who sets them has misunderstood the destination. Signed-off-by: Sandy Chapman <schapman@nvidia.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Sandy Chapman <schapman@nvidia.com>
Signed-off-by: Sandy Chapman <schapman@nvidia.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Sandy Chapman <schapman@nvidia.com>
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: trueComment |
License DiffCompared against Lockfile license changesLockfile License ChangesRustAdded
Removed
Updated/Changed
NodeAdded
Removed
Updated/Changed
PythonAdded
Removed
Updated/Changed
Status output |
`opentelemetry-proto`'s `with-serde` feature, which provides the OTLP/JSON serialization the file exporter's default format needs, adds `const-hex` to the graph and its dev dependencies to the lock. Signed-off-by: Sandy Chapman <schapman@nvidia.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Sandy Chapman <schapman@nvidia.com>
willkill07
left a comment
There was a problem hiding this comment.
I feel like this needs a proper proposal rather than instructing Claude to implement the functionality loosely based on need.
I understand this is in draft form, but it isn't conducive to any significant review time without a corresponding proposal.
Ultimately, we need a configuration shape for the new functionality without breaking existing plugin configuration files.
| max_export_batch_size: Option<usize>, | ||
| scheduled_delay: Option<Duration>, | ||
| completed_span_context_ttl: Duration, | ||
| file_sink: Option<OtlpFileSinkSettings>, |
There was a problem hiding this comment.
This is incompatible with several other options. Given that we permit programmatic modification of plugin configuration through nemo-relay plugins edit this naive extension is not appropriate.
transport and endpoint must be mutually exclusive with file_sink. No silent ignore behavior.
| )); | ||
| // A file sink has no endpoint to validate; everything below this point | ||
| // is destination-independent. | ||
| if config.file_sink.is_none() { |
There was a problem hiding this comment.
Inappropriate for implementation
| /// transport: it has no endpoint, no headers, and no timeout, so it is modelled | ||
| /// separately instead of as an [`OtlpTransport`] variant. | ||
| #[derive(Debug, Clone, PartialEq, Eq)] | ||
| pub struct OtlpFileSinkSettings { |
There was a problem hiding this comment.
This doesn't match the OTel alpha proposal for options including rotation/file size limits. If we implement this, I'd like for there to be feature parity with the alpha-level proposal.
Overview
Relay's OpenTelemetry plugin can only ship spans to a collector. ATIF and ATOF both write to disk, so a consumer that treats a trajectory as an artifact rather than as telemetry has no OTLP option at all. This adds
[[components.config.opentelemetry.file_sinks]], which writes the sameExportTraceServiceRequestan endpoint would receive to a local file.The argument for a sink here rather than a collector is that it removes a routable address and an egress rule for isolated sandboxes. Additionally, some users using Fabric+Relay for evaluation, may not have provision the infrastructure required to collect traces.
Note for reviewers: a companion change is needed in NeMo-Fabric
This change is necessary but not sufficient for the evaluation use case.
Callers that reach Relay through Fabric use
nemo_fabric.RelayOpenTelemetryConfig, which has two fields (enabled,endpoints) and a hand-written validator in its ownmodels.py:Checked against
nemo-fabric0.3.0b1:extra="allow", sofile_sinkssurvives serialization and reaches Relay untouched.Fabric needs
file_sinkson the model and the validator relaxed to accept a file sink as a destination, which is the same shape as thevalidate_opentelemetry_sectionchange in the second commit here.Details
Destination, not transport. A file sink has no endpoint, headers, or timeout, and endpoint validation does not apply to it, so it is modelled as a separate destination on
OpenTelemetryConfigrather than as a thirdOtlpTransportvariant. Configuration mirrors the existing ATOF file sink (output_directory,filename,mode) rather than overloadingendpointwith a path.Everything above the exporter — projection, id generation, batching, resource attributes, shutdown — is shared, so the two destination kinds cannot drift on what a span means.
SpanExporter::exportreturnsimpl Future, so the trait is not dyn-compatible and dispatch is a concrete enum rather than a boxed trait object.Conversion is upstream.
group_spans_by_resource_and_scopeand theSpanData→ protobufFromimpls come fromopentelemetry-proto, the crateopentelemetry-otlpalready uses to build its wire payload. Bothopentelemetry-protoandprostwere already incrates/core/Cargo.tomlunder[dev-dependencies]; they move to[dependencies]with thetraceandwith-serdefeatures. No new crates and no version changes.Durability and permissions. Each export is flushed before it is reported as delivered, so a run that exits between batches leaves a readable prefix rather than an empty file. Output is created with the same owner-only permissions and directory confinement as the ATOF and ATIF sinks, because a trajectory carries prompt and response content. Two sinks writing one path are rejected at activation.
Surfaces.
nemo-relay configurelists file sinks beside trace endpoints; Python, Node, Go, and the FFI each gain the destination.Where should the reviewer start?
crates/core/src/observability/otel_file.rs— the exporter is ~200 lines and the whole design is visible there. Thencrates/core/tests/unit/observability/otel_file_tests.rs: the round-trips decode withprostandserde_jsondirectly rather than through this module's own encoder, so a writer and reader that agree only with each other cannot pass.json_lines_encode_span_identifiers_as_hexis the OTLP/JSON conformance check.The design decision worth arguing about is
file_sinksas a separate array versus atransport = "file"variant onendpoints. I chose the former becauseendpointis required and URL-validated, and conditional validation on a field that sometimes holds a path seemed worse than a second array.Testing
cargo test --workspace— 1713 core lib tests pass. 39 new tests: exporter round-trip and framing, config validation, editor schema, and per-binding coverage.native_plugin_integrationneedsjust build-test-plugin-fixtures, which I could not run locally.cargo clippy --workspace --all-targetsandcargo fmt --allclean.node --test crates/node/tests/observability_plugin_tests.mjs— 10/10. One test drives a file-sink-only section through the plugin host and asserts the trace file appears.pytest python/tests/test_types.py— 59/59,ruffclean,tyclean (9 pre-existing unused-ignore warnings).go testonotel_test.gopasses;gofmtandgo vetclean.cargo deny(not installed) and the fixture-dependent native plugin integration tests.Breaking changes
None.
file_sinksdefaults to empty and every existing configuration behaves identically.Related Issues:
None — raised on Slack rather than as a GitHub issue. Happy to file one if you would like the paper trail before review.