Skip to content

fix: add gRPC length-prefixed framing to OTLP gRPC output - #628

Merged
strawgate merged 5 commits into
masterfrom
copilot/fix-otlp-grpc-framing
Apr 2, 2026
Merged

strawgate merged 5 commits into
masterfrom
copilot/fix-otlp-grpc-framing

Conversation

Copilot AI commented Apr 2, 2026 •

Copy link
Copy Markdown
Contributor

OtlpSink sent raw protobuf bytes for both HTTP and gRPC protocols — only the Content-Type header differed. gRPC requires every message to be wrapped in a 5-byte frame header before the protobuf payload; without it, any standard gRPC collector rejects the request.

Changes

  • grpc_frame(payload) — new helper that prepends the gRPC wire frame:
    [0x00 (not compressed)] [4 bytes big-endian length] [N bytes protobuf]
    
  • send_batch — wraps the payload through grpc_frame when protocol == OtlpProtocol::Grpc using a maybe-initialized variable to avoid an Option wrapper:
    let grpc_buf: Vec<u8>;
    let payload: &[u8] = if self.protocol == OtlpProtocol::Grpc {
        grpc_buf = grpc_frame(payload);
        &grpc_buf
    } else {
        payload
    };
  • HTTP/2 caveat — added a comment that ureq is HTTP/1.1 only; a real gRPC endpoint requires HTTP/2 (use Envoy sidecar or protocol: http as workaround).
  • Tests — grpc_frame_prepends_five_byte_header and grpc_frame_empty_payload verify the framing invariants.

Copilot AI changed the title [WIP] Fix missing length-prefixed framing in OTLP gRPC output fix: add gRPC length-prefixed framing to OTLP gRPC output Apr 2, 2026
Copilot AI requested a review from strawgate April 2, 2026 01:59
@strawgate
strawgate marked this pull request as ready for review April 2, 2026 03:07
@strawgate

Copy link
Copy Markdown
Owner

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Apr 2, 2026

Copy link
Copy Markdown
✅ Actions performed

Full review triggered.

@coderabbitai

coderabbitai Bot commented Apr 2, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Bot user detected.

To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 8037a74f-ead0-4faa-ad17-84093fc6330e

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

The OtlpSink::send_batch method now conditionally frames protobuf payloads for gRPC transport. When the protocol is set to gRPC, outgoing messages are wrapped with a 5-byte header containing a compression flag (0x00) and the message length as a big-endian u32 integer. HTTP transport continues sending raw protobuf bytes unchanged. A new private grpc_frame() helper function constructs this framing, accompanied by unit tests validating correct header generation for various payload sizes.


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

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

Inline comments:
In `@crates/logfwd-output/src/otlp_sink.rs`:
- Around line 741-746: The CI failure is due to rustfmt formatting drift in the
assert block using assert_eq! with msg_len, proto_payload and framed; fix by
running rustfmt (cargo fmt --all) to reformat
crates/logfwd-output/src/otlp_sink.rs so the two assert_eq! calls
(assert_eq!(msg_len as usize, proto_payload.len(), ...) and
assert_eq!(&framed[5..], &proto_payload, ...)) conform to project style, then
stage and commit the reformatted file(s).
- Around line 189-195: The send_batch hot path currently allocates a new Vec
each batch via grpc_buf = grpc_frame(payload); instead reuse a sink-owned buffer
(e.g., add a field self.grpc_buf: Vec<u8>) and write the framed bytes into it
instead of allocating: clear the buffer (self.grpc_buf.clear()), call grpc_frame
or an equivalent framing helper to append into self.grpc_buf, then set
payload_ref to &self.grpc_buf when self.protocol == OtlpProtocol::Grpc; replace
the local grpc_buf usage with self.grpc_buf to avoid per-batch allocations in
send_batch.
- Around line 185-195: The current gRPC path frames already-compressed messages
with grpc_frame(payload) using the wrong compression flag and relies on HTTP
Content-Encoding (which gRPC ignores); fast safe fix: when self.protocol ==
OtlpProtocol::Grpc, detect if the outgoing payload is already compressed and
reject or remap that path instead of sending an invalid gRPC message — e.g., in
the send path where grpc_frame(payload) is chosen, return an error if a
Content-Encoding (or caller-provided compression marker) is set, or refuse to
use OtlpProtocol::Grpc with compressed payloads; also avoid per-batch Vec<u8>
allocations by reusing a preallocated buffer (move the temporary grpc_buf into a
reusable buffer on the sink struct or use a shared bytes buffer) so grpc_frame
no longer allocates each batch. Ensure you modify the branch that checks
self.protocol == OtlpProtocol::Grpc and the grpc_frame usage accordingly.
🪄 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: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 1386cb8f-8ac5-48ff-914b-a4a163caf47c

📥 Commits

Reviewing files that changed from the base of the PR and between 1913d88 and a073058.

📒 Files selected for processing (1)
  • crates/logfwd-output/src/otlp_sink.rs

Comment on lines +185 to +195
// For gRPC, prepend the 5-byte length-prefixed frame header required by the
// gRPC wire protocol. Note: ureq uses HTTP/1.1; a true gRPC endpoint requires
// HTTP/2. Use a reverse proxy (e.g. Envoy) or `protocol: http` when the
// collector does not accept HTTP/1.1 upgrades.
let grpc_buf: Vec<u8>;
let payload: &[u8] = if self.protocol == OtlpProtocol::Grpc {
grpc_buf = grpc_frame(payload);
&grpc_buf
} else {
payload
};

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 | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

In gRPC over HTTP/2, when message payload is compressed, must the 1-byte gRPC frame compressed flag be set to 1 and grpc-encodingbe used (instead of HTTPContent-Encoding)?

💡 Result:

Yes, in gRPC over HTTP/2, when the message payload is compressed, the 1-byte gRPC frame compressed flag must be set to 1, and the grpc-encoding header (referred to as Message-Encoding in the protocol spec) must be used to declare the compression mechanism (e.g., "gzip"). The standard HTTP Content-Encoding header is not used for gRPC message compression; instead, gRPC defines its own grpc-encoding header for this purpose. If grpc-encoding is omitted (or set to "identity"), the compressed flag must be 0. Setting the flag to 1 without a corresponding non-identity grpc-encoding results in an INTERNAL error.

Citations:


🏁 Script executed:

cd crates/logfwd-output && wc -l src/otlp_sink.rs

Repository: strawgate/memagent

Length of output: 82


🏁 Script executed:

cd crates/logfwd-output && sed -n '160,210p' src/otlp_sink.rs | cat -n

Repository: strawgate/memagent

Length of output: 2687


🏁 Script executed:

cd crates/logfwd-output && sed -n '555,580p' src/otlp_sink.rs | cat -n

Repository: strawgate/memagent

Length of output: 1062


🏁 Script executed:

cd crates/logfwd-output && sed -n '185,200p' src/otlp_sink.rs | cat -n

Repository: strawgate/memagent

Length of output: 1014


gRPC compression requires frame flag=1 and grpc-encoding header; current code breaks decompression.

Lines 185–195 frame already-compressed payload with flag 0x00 (line 565), but gRPC spec requires flag 0x01 and the grpc-encoding header when message is compressed. Current code sends Content-Encoding: zstd (line 210), which gRPC ignores for message compression. Receivers will fail to decompress with INTERNAL error.

Secondary: per-batch Vec<u8> allocation (line 190) in hot path should be reused or buffered.

Safe immediate fix: reject unsupported combination
 fn send_batch(&mut self, batch: &RecordBatch, metadata: &BatchMetadata) -> io::Result<()> {
+    if self.protocol == OtlpProtocol::Grpc && self.compression != Compression::None {
+        return Err(io::Error::new(
+            io::ErrorKind::InvalidInput,
+            "OTLP gRPC compression requires grpc-encoding signaling and is not implemented",
+        ));
+    }
+
     self.encode_batch(batch, metadata);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/logfwd-output/src/otlp_sink.rs` around lines 185 - 195, The current
gRPC path frames already-compressed messages with grpc_frame(payload) using the
wrong compression flag and relies on HTTP Content-Encoding (which gRPC ignores);
fast safe fix: when self.protocol == OtlpProtocol::Grpc, detect if the outgoing
payload is already compressed and reject or remap that path instead of sending
an invalid gRPC message — e.g., in the send path where grpc_frame(payload) is
chosen, return an error if a Content-Encoding (or caller-provided compression
marker) is set, or refuse to use OtlpProtocol::Grpc with compressed payloads;
also avoid per-batch Vec<u8> allocations by reusing a preallocated buffer (move
the temporary grpc_buf into a reusable buffer on the sink struct or use a shared
bytes buffer) so grpc_frame no longer allocates each batch. Ensure you modify
the branch that checks self.protocol == OtlpProtocol::Grpc and the grpc_frame
usage accordingly.

Comment thread crates/logfwd-output/src/otlp_sink.rs Outdated
Comment on lines +189 to +195
let grpc_buf: Vec<u8>;
let payload: &[u8] = if self.protocol == OtlpProtocol::Grpc {
grpc_buf = grpc_frame(payload);
&grpc_buf
} else {
payload
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Avoid per-batch allocation for gRPC framing buffer in send_batch.

At Line 189-193, grpc_buf = grpc_frame(payload) allocates a new Vec<u8> each batch. Reuse a sink-owned buffer (self.grpc_buf) to reduce allocator churn in the output hot path.

♻️ Suggested refactor (reuse buffer)
 pub struct OtlpSink {
     name: String,
     endpoint: String,
     protocol: OtlpProtocol,
     compression: Compression,
     headers: Vec<(String, String)>,
     pub(crate) encoder_buf: Vec<u8>,
     compress_buf: Vec<u8>,
+    grpc_buf: Vec<u8>,
     compressor: Option<ChunkCompressor>,
     http_agent: ureq::Agent,
     stats: Arc<ComponentStats>,
 }
@@
         OtlpSink {
@@
             encoder_buf: Vec::with_capacity(64 * 1024),
             compress_buf: Vec::with_capacity(64 * 1024),
+            grpc_buf: Vec::with_capacity(64 * 1024),
             compressor,
             http_agent,
             stats,
         }
@@
-        let grpc_buf: Vec<u8>;
         let payload: &[u8] = if self.protocol == OtlpProtocol::Grpc {
-            grpc_buf = grpc_frame(payload);
-            &grpc_buf
+            self.grpc_buf.clear();
+            self.grpc_buf.reserve(5 + payload.len());
+            self.grpc_buf.push(0x00);
+            self.grpc_buf
+                .extend_from_slice(&(payload.len() as u32).to_be_bytes());
+            self.grpc_buf.extend_from_slice(payload);
+            &self.grpc_buf
         } else {
             payload
         };

As per coding guidelines: "Do not introduce allocations in hot paths without benchmarking".

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

In `@crates/logfwd-output/src/otlp_sink.rs` around lines 189 - 195, The send_batch
hot path currently allocates a new Vec each batch via grpc_buf =
grpc_frame(payload); instead reuse a sink-owned buffer (e.g., add a field
self.grpc_buf: Vec<u8>) and write the framed bytes into it instead of
allocating: clear the buffer (self.grpc_buf.clear()), call grpc_frame or an
equivalent framing helper to append into self.grpc_buf, then set payload_ref to
&self.grpc_buf when self.protocol == OtlpProtocol::Grpc; replace the local
grpc_buf usage with self.grpc_buf to avoid per-batch allocations in send_batch.

Comment thread crates/logfwd-output/src/otlp_sink.rs Outdated
Comment on lines +741 to +746
assert_eq!(
msg_len as usize,
proto_payload.len(),
"length field must match payload length"
);
assert_eq!(&framed[5..], &proto_payload, "payload bytes must follow header");

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

CI blocker: rustfmt mismatch in this assert block.

Pipeline already reports formatting drift around this assert (Line 741-746). Please run cargo fmt --all before merge.

🧰 Tools
🪛 GitHub Actions: CI

[error] 743-746: cargo fmt --check failed: formatting diff in assert_eq! message formatting. Run cargo fmt (or cargo fmt --all) to apply the expected rustfmt changes.

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

In `@crates/logfwd-output/src/otlp_sink.rs` around lines 741 - 746, The CI failure
is due to rustfmt formatting drift in the assert block using assert_eq! with
msg_len, proto_payload and framed; fix by running rustfmt (cargo fmt --all) to
reformat crates/logfwd-output/src/otlp_sink.rs so the two assert_eq! calls
(assert_eq!(msg_len as usize, proto_payload.len(), ...) and
assert_eq!(&framed[5..], &proto_payload, ...)) conform to project style, then
stage and commit the reformatted file(s).

strawgate and others added 3 commits April 2, 2026 00:10
Resolve conflict in otlp_sink.rs: keep both encode_key_value_bool
(from master) and gRPC framing changes (from this branch).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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.

bug: OTLP gRPC output missing length-prefixed framing — all gRPC sends are malformed

2 participants