Skip to main content

qlog/
writer.rs

1// Copyright (C) 2026, Cloudflare, Inc.
2// All rights reserved.
3//
4// Redistribution and use in source and binary forms, with or without
5// modification, are permitted provided that the following conditions are
6// met:
7//
8//     * Redistributions of source code must retain the above copyright notice,
9//       this list of conditions and the following disclaimer.
10//
11//     * Redistributions in binary form must reproduce the above copyright
12//       notice, this list of conditions and the following disclaimer in the
13//       documentation and/or other materials provided with the distribution.
14//
15// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
16// IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
17// THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
18// PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
19// CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
20// EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
21// PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
22// PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
23// LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
24// NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
25// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
26
27//! QLOG file writer plumbing -- compression-aware companion to
28//! [`crate::reader`].
29//!
30//! This module owns the writer-side surface for emitting JSON-SEQ
31//! qlog streams, optionally wrapped in a streaming compressor. It is
32//! a deliberate exception to the qlog crate's otherwise pure-data
33//! posture: the writer helpers, the compression enum, and the
34//! filename / extension conventions live here so any consumer
35//! (tokio-quiche, [`crate::reader::QlogSeqReader::with_file`],
36//! external tooling) shares a single canonical writer story.
37//!
38//! Compression is opt-in via Cargo features:
39//! * `gzip` pulls in [`flate2`].
40//! * `zstd` pulls in the [`zstd`] crate (C dependency via `zstd-sys`).
41//!
42//! The [`QlogCompression`] enum's `Gzip` and `Zstd` variants are
43//! compile-time gated on their respective features, so a build that
44//! disables one of them cannot construct the unsupported variant.
45//!
46//! Bytes flow `producer -> [compressor] -> W` where `W` is any
47//! `Write + Send + Sync + 'static`. No buffering is added here: both
48//! `flate2` and `zstd` emit output in large chunks (DEFLATE blocks /
49//! zstd frames), so an extra `BufWriter` is redundant; callers are
50//! free to add one if they need to.
51
52use std::fs::File;
53use std::io;
54use std::io::Write;
55use std::path::Path;
56
57use crate::SQLOG_EXT;
58#[cfg(feature = "gzip")]
59use crate::SQLOG_GZ_EXT;
60#[cfg(feature = "zstd")]
61use crate::SQLOG_ZST_EXT;
62
63/// Boxed `Write` returned by [`make_qlog_writer`] /
64/// [`make_qlog_writer_from_path`].
65///
66/// Producers (e.g. `quiche::Connection::set_qlog`) typically require
67/// `Send + Sync`; the boxed form keeps the writer object-safe across
68/// the compression-vs-no-compression branches.
69pub type QlogFileWriter = Box<dyn Write + Send + Sync>;
70
71/// Compression algorithm applied to QLOG output streams.
72///
73/// `None` is always available. `Gzip` and `Zstd` are compile-time
74/// gated on the `gzip` and `zstd` Cargo features respectively; a
75/// build that disables one of those features cannot reference the
76/// corresponding variant.
77#[derive(
78    Clone,
79    Copy,
80    Debug,
81    Default,
82    Eq,
83    PartialEq,
84    serde::Deserialize,
85    serde::Serialize,
86)]
87#[serde(rename_all = "snake_case")]
88pub enum QlogCompression {
89    /// No compression. Emit raw `.sqlog` files.
90    #[default]
91    None,
92    /// Gzip streaming compression (DEFLATE + gzip framing). Emits
93    /// `.sqlog.gz` files. Requires the `gzip` Cargo feature.
94    #[cfg(feature = "gzip")]
95    Gzip,
96    /// Zstd streaming compression. Emits `.sqlog.zst` files. Requires
97    /// the `zstd` Cargo feature.
98    #[cfg(feature = "zstd")]
99    Zstd,
100}
101
102#[cfg(feature = "foundations")]
103impl foundations::settings::Settings for QlogCompression {}
104
105/// Return the qlog filename (not including the directory) for a
106/// stream whose identifier is `id`, with the suffix matching
107/// `compression`: `<id>.sqlog`, `<id>.sqlog.gz`, or `<id>.sqlog.zst`.
108pub fn qlog_file_name(id: &str, compression: QlogCompression) -> String {
109    match compression {
110        QlogCompression::None => format!("{id}{SQLOG_EXT}"),
111        #[cfg(feature = "gzip")]
112        QlogCompression::Gzip => format!("{id}{SQLOG_GZ_EXT}"),
113        #[cfg(feature = "zstd")]
114        QlogCompression::Zstd => format!("{id}{SQLOG_ZST_EXT}"),
115    }
116}
117
118/// Wrap `inner` in the streaming encoder selected by `compression`
119/// and return a boxed `Write` that a qlog producer (e.g. quiche via
120/// `set_qlog`) writes into.
121///
122/// The generic `W` bound lets production call sites pass a
123/// `std::fs::File` while tests can pass an in-process buffer (e.g.
124/// `Vec<u8>`). For the common case of "open a file at this path and
125/// pick the compressor by extension" use
126/// [`make_qlog_writer_from_path`].
127///
128/// No buffering is added here.
129pub fn make_qlog_writer<W>(
130    inner: W, compression: QlogCompression,
131) -> io::Result<QlogFileWriter>
132where
133    W: Write + Send + Sync + 'static,
134{
135    match compression {
136        QlogCompression::None => Ok(Box::new(inner)),
137        #[cfg(feature = "gzip")]
138        QlogCompression::Gzip => {
139            let encoder = flate2::write::GzEncoder::new(
140                inner,
141                flate2::Compression::default(),
142            );
143            Ok(Box::new(encoder))
144        },
145        #[cfg(feature = "zstd")]
146        QlogCompression::Zstd => {
147            // Level 3 is the zstd default: a balanced point on the
148            // ratio-vs-speed curve.
149            let encoder = zstd::Encoder::new(inner, 3)?;
150            Ok(Box::new(ZstdFinishOnDrop {
151                encoder: Some(encoder),
152            }))
153        },
154    }
155}
156
157/// Convenience function to create a File at `path` with a writer
158/// based on `compression`.
159///
160/// Equivalent to manually creating a File and passing it to
161/// [`make_qlog_writer`].
162pub fn make_qlog_writer_from_path<P: AsRef<Path>>(
163    path: P, compression: QlogCompression,
164) -> io::Result<QlogFileWriter> {
165    let file = File::create(path.as_ref())?;
166    make_qlog_writer(file, compression)
167}
168
169/// `Write` wrapper that calls [`zstd::Encoder::finish`] on drop so
170/// the zstd frame trailer is written to the inner sink.
171///
172/// `zstd::Encoder` does not flush its frame trailer implicitly on
173/// drop, so a qlog stream written through a bare `Encoder` would be
174/// missing the end-of-frame marker and fail to decode.
175/// `AutoFinishEncoder` from the `zstd` crate solves this but is
176/// `!Sync` (it stores a user-supplied `FnMut` closure), while some
177/// producers (e.g. `quiche::Connection::set_qlog`) require
178/// `Send + Sync`. This local wrapper preserves those bounds because
179/// it only holds an `Option<Encoder<_, W>>`, where `Encoder` is
180/// `Send + Sync` whenever `W` is.
181#[cfg(feature = "zstd")]
182struct ZstdFinishOnDrop<W: Write> {
183    encoder: Option<zstd::Encoder<'static, W>>,
184}
185
186#[cfg(feature = "zstd")]
187impl<W: Write> Write for ZstdFinishOnDrop<W> {
188    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
189        self.encoder
190            .as_mut()
191            .expect("encoder present until drop")
192            .write(buf)
193    }
194
195    fn flush(&mut self) -> io::Result<()> {
196        self.encoder
197            .as_mut()
198            .expect("encoder present until drop")
199            .flush()
200    }
201}
202
203#[cfg(feature = "zstd")]
204impl<W: Write> Drop for ZstdFinishOnDrop<W> {
205    fn drop(&mut self) {
206        if let Some(encoder) = self.encoder.take() {
207            if let Err(error) = encoder.finish() {
208                // qlog crate has no structured-logging dependency; use
209                // `eprintln!` so trailer-flush failures surface on
210                // stderr rather than silently truncating the stream.
211                eprintln!("qlog: failed to finish zstd encoder: {error}");
212            }
213        }
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn file_name_uses_constants_for_none() {
223        let name = qlog_file_name("abc", QlogCompression::None);
224        assert_eq!(name, "abc.sqlog");
225        assert!(name.ends_with(SQLOG_EXT));
226    }
227
228    #[cfg(feature = "gzip")]
229    #[test]
230    fn file_name_uses_constants_for_gzip() {
231        let name = qlog_file_name("abc", QlogCompression::Gzip);
232        assert_eq!(name, "abc.sqlog.gz");
233        assert!(name.ends_with(SQLOG_GZ_EXT));
234    }
235
236    #[cfg(feature = "zstd")]
237    #[test]
238    fn file_name_uses_constants_for_zstd() {
239        let name = qlog_file_name("abc", QlogCompression::Zstd);
240        assert_eq!(name, "abc.sqlog.zst");
241        assert!(name.ends_with(SQLOG_ZST_EXT));
242    }
243}