<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Log10x</title>
    <description>The latest articles on DEV Community by Log10x (log10x).</description>
    <link>https://dev.to/log10x</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Forganization%2Fprofile_image%2F15644%2Fc207f039-7d44-4ba7-bf1c-d0f7078e4791.png</url>
      <title>DEV Community: Log10x</title>
      <link>https://dev.to/log10x</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/log10x"/>
    <language>en</language>
    <item>
      <title>Log compaction that stays searchable, measured on 15 public datasets</title>
      <dc:creator>Tal Weiss</dc:creator>
      <pubDate>Thu, 10 Sep 2026 16:47:47 +0000</pubDate>
      <link>https://dev.to/log10x/log-compaction-that-stays-searchable-measured-on-15-public-datasets-cc2</link>
      <guid>https://dev.to/log10x/log-compaction-that-stays-searchable-measured-on-15-public-datasets-cc2</guid>
      <description>&lt;p&gt;A log platform (Splunk, for example) meters ingest by the byte, and most bytes in a log line are repetitive text. Part of it is what the code prints: the words of a format string, which are identical every time that statement runs. The rest is added around the message: the level, the service and pod names, the fields a logging framework stamps on every line (in Java, the MDC).&lt;/p&gt;

&lt;p&gt;10x runs as a sidecar to the log forwarder, stores each line's repeated text once as a template, and ships each line as that template's hash plus the line's values.&lt;/p&gt;

&lt;p&gt;Because each compact line is still a row the log platform can query, the reduction stays searchable in place instead of sitting in a gzip blob. The read path for &lt;a href="https://www.log10x.com/blog/cutting-splunk-log-storage/?utm_source=dev.to&amp;amp;utm_medium=article&amp;amp;utm_campaign=cohort1"&gt;Splunk&lt;/a&gt; and &lt;a href="https://www.log10x.com/blog/cutting-elasticsearch-log-storage/?utm_source=dev.to&amp;amp;utm_medium=article&amp;amp;utm_campaign=cohort1"&gt;Elasticsearch&lt;/a&gt; is built in the sibling posts, and on &lt;a href="https://www.log10x.com/blog/clickhouse-offload-merge-table/?utm_source=dev.to&amp;amp;utm_medium=article&amp;amp;utm_campaign=cohort1"&gt;ClickHouse&lt;/a&gt; a Merge table reads offloaded lines from the bucket beside the table.&lt;/p&gt;

&lt;p&gt;Here is one line from a Fluentd container, as the forwarder read it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2025-10-02 00:17:22 +0000 [info]: #0 starting fluentd worker pid=18 ppid=6 worker=0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The engine stores the repeated part of that line once, as a template. Each &lt;code&gt;$&lt;/code&gt; marks a slot where a value goes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"templateHash"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"9aZXwGwVG/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"template"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"$(yyyy-MM-dd HH:mm:ss) +$ [info]: #0 starting fluentd worker pid=$ ppid=$ worker=$"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The line itself ships as a single row. After the leading &lt;code&gt;~&lt;/code&gt; comes the template's hash, the same &lt;code&gt;9aZXwGwVG/&lt;/code&gt; as in the dictionary entry above, and then that line's values in order: the timestamp as epoch milliseconds, the timezone digits, and the three numbers.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csvs"&gt;&lt;code&gt;&lt;span class="err"&gt;~&lt;/span&gt;&lt;span class="mf"&gt;9&lt;/span&gt;&lt;span class="k"&gt;aZXwGwVG&lt;/span&gt;&lt;span class="err"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mf"&gt;1759364242000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mf"&gt;0000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mf"&gt;18&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mf"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mf"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reverse step, expand, splices the values back into the template's slots and returns the original line, as the round-trip check further down measures.&lt;/p&gt;

&lt;p&gt;This post measures the reduction on fifteen public datasets, run through the engine in its stock configuration: a 215 MB stream of Kubernetes container output, captured through Fluentd from a cluster running the OpenTelemetry demo, plus 14 of the 16 public &lt;a href="https://github.com/logpai/loghub" rel="noopener noreferrer"&gt;LogHub&lt;/a&gt; datasets at full size.&lt;/p&gt;

&lt;p&gt;The Docker image these runs used, &lt;a href="https://hub.docker.com/r/log10x/pipeline-10x" rel="noopener noreferrer"&gt;log10x/pipeline-10x&lt;/a&gt;, is public, as are all fifteen datasets, so every number below reruns with the one command at the end of the post.&lt;/p&gt;

&lt;h2&gt;
  
  
  Inputs and method
&lt;/h2&gt;

&lt;p&gt;Each run is one pass of the engine over one dataset: the original file goes in, and two files come out, the encoded events and the template dictionary. The LogHub datasets are the full files from the project's &lt;a href="https://zenodo.org/records/3227177" rel="noopener noreferrer"&gt;Zenodo record&lt;/a&gt; rather than its 2,000-line benchmark excerpts. A full file reduces a few points more than the excerpt, because the one-time dictionary is spread over more events.&lt;/p&gt;

&lt;p&gt;The two LogHub sets left out, Thunderbird (29.6 GB) and Windows (26 GB), are the collection's largest and get a separate run, so the weighted figure below covers the 14 included sets rather than LogHub as a whole.&lt;/p&gt;

&lt;p&gt;The engine tokenizes each line and matches the tokens against a &lt;a href="https://www.log10x.com/blog/where-log-structure-actually-lives/?utm_source=dev.to&amp;amp;utm_medium=article&amp;amp;utm_campaign=cohort1"&gt;symbol library&lt;/a&gt;, which is the vocabulary of recurring tokens it recognizes. A library can be compiled from a specific codebase, and none of these runs did that. Every dataset went through the &lt;a href="https://doc.log10x.com/compile/pull/#default-symbols" rel="noopener noreferrer"&gt;default symbol library&lt;/a&gt; that ships in the image, which is what a first install runs.&lt;/p&gt;

&lt;p&gt;Reduction means the bytes of the compact form (the encoded events plus the template dictionary needed to expand them) against the bytes of the original file, with no gzip or ZSTD on either side.&lt;/p&gt;

&lt;p&gt;10x compacts logs before the platform meters them: a sidecar to the log forwarder stores the repeated text once as a template and ships each line as the template's hash plus values, and expand returns the original line. Compact lines stay queryable in place rather than sitting in a gzip file. Measured on fifteen public datasets, a 215 MB Kubernetes stream reduced 64%, and 14 full LogHub datasets 44.7% overall, from 4.7% to 58.4%.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the runs measured
&lt;/h3&gt;

&lt;p&gt;All fifteen datasets are below, sorted by reduction. Every row except OTel-demo is a LogHub set.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dataset&lt;/th&gt;
&lt;th&gt;Input&lt;/th&gt;
&lt;th&gt;Reduction&lt;/th&gt;
&lt;th&gt;Templates&lt;/th&gt;
&lt;th&gt;Events&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;OTel-demo&lt;/td&gt;
&lt;td&gt;215 MB&lt;/td&gt;
&lt;td&gt;64%&lt;/td&gt;
&lt;td&gt;3,028&lt;/td&gt;
&lt;td&gt;157k&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hadoop&lt;/td&gt;
&lt;td&gt;48.6 MB&lt;/td&gt;
&lt;td&gt;58.4%&lt;/td&gt;
&lt;td&gt;888&lt;/td&gt;
&lt;td&gt;187k&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spark&lt;/td&gt;
&lt;td&gt;2.94 GB&lt;/td&gt;
&lt;td&gt;57.5%&lt;/td&gt;
&lt;td&gt;3,377&lt;/td&gt;
&lt;td&gt;33M&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Apache&lt;/td&gt;
&lt;td&gt;5.1 MB&lt;/td&gt;
&lt;td&gt;55.3%&lt;/td&gt;
&lt;td&gt;371&lt;/td&gt;
&lt;td&gt;52k&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenSSH&lt;/td&gt;
&lt;td&gt;73.4 MB&lt;/td&gt;
&lt;td&gt;49.8%&lt;/td&gt;
&lt;td&gt;3,032&lt;/td&gt;
&lt;td&gt;655k&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Proxifier&lt;/td&gt;
&lt;td&gt;2.5 MB&lt;/td&gt;
&lt;td&gt;46.7%&lt;/td&gt;
&lt;td&gt;1,854&lt;/td&gt;
&lt;td&gt;21k&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Zookeeper&lt;/td&gt;
&lt;td&gt;10.4 MB&lt;/td&gt;
&lt;td&gt;45.6%&lt;/td&gt;
&lt;td&gt;157&lt;/td&gt;
&lt;td&gt;74k&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Linux&lt;/td&gt;
&lt;td&gt;2.3 MB&lt;/td&gt;
&lt;td&gt;44.5%&lt;/td&gt;
&lt;td&gt;1,266&lt;/td&gt;
&lt;td&gt;26k&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HPC&lt;/td&gt;
&lt;td&gt;33.6 MB&lt;/td&gt;
&lt;td&gt;38.9%&lt;/td&gt;
&lt;td&gt;2,045&lt;/td&gt;
&lt;td&gt;330k&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BGL&lt;/td&gt;
&lt;td&gt;743 MB&lt;/td&gt;
&lt;td&gt;35.1%&lt;/td&gt;
&lt;td&gt;136,461&lt;/td&gt;
&lt;td&gt;4.7M&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mac&lt;/td&gt;
&lt;td&gt;16.9 MB&lt;/td&gt;
&lt;td&gt;32.5%&lt;/td&gt;
&lt;td&gt;2,466&lt;/td&gt;
&lt;td&gt;107k&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenStack&lt;/td&gt;
&lt;td&gt;61.4 MB&lt;/td&gt;
&lt;td&gt;30.6%&lt;/td&gt;
&lt;td&gt;8,133&lt;/td&gt;
&lt;td&gt;208k&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HDFS&lt;/td&gt;
&lt;td&gt;1.58 GB&lt;/td&gt;
&lt;td&gt;29.1%&lt;/td&gt;
&lt;td&gt;279&lt;/td&gt;
&lt;td&gt;11.2M&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Android&lt;/td&gt;
&lt;td&gt;192 MB&lt;/td&gt;
&lt;td&gt;20%&lt;/td&gt;
&lt;td&gt;29,400&lt;/td&gt;
&lt;td&gt;1.6M&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HealthApp&lt;/td&gt;
&lt;td&gt;23.5 MB&lt;/td&gt;
&lt;td&gt;4.7%&lt;/td&gt;
&lt;td&gt;1,341&lt;/td&gt;
&lt;td&gt;205k&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Events column counts events after multi-line grouping, which the stock configuration uses to join runs of consecutive related raw lines into one event. That is how the 215 MB stream's 197,430 raw lines count as 157,228 events. No line is dropped, and expand returns the raw lines.&lt;/p&gt;

&lt;p&gt;The 215 MB stream is the widest public data offers, 36 Kubernetes containers with infrastructure included, and it compacts 64%. The 14 LogHub datasets hold 52,276,243 events and 5.73 GB of input. Weighted by input bytes, they reduce 44.7%.&lt;/p&gt;

&lt;h3&gt;
  
  
  The spread tracks how much of each line is repeated text
&lt;/h3&gt;

&lt;p&gt;The reduction tracks how much of each line is repeated text and how many lines share each template rather than which application wrote it.&lt;/p&gt;

&lt;p&gt;HealthApp (4.7%) and Android (20%) are the floor. Both are mobile logs whose component names and tags ship as values, beside the timestamps and IDs, so little of each line is repeated text. HealthApp's most common template, &lt;code&gt;$(yyyyMMdd-H:m:s:SSS)|Step_$|$|$ $=$&lt;/code&gt;, keeps only &lt;code&gt;|Step_&lt;/code&gt; and the separators.&lt;/p&gt;

&lt;p&gt;The middle of the table, 29% to 58%, sorts by the same variable. HDFS and OpenStack sit at the low end because their lines are mostly values: block IDs, PIDs, and byte counts, with little fixed text between them, and the values ship on every event regardless. Hadoop, Spark, and Apache sit at the high end because they write more repeated message text around their values.&lt;/p&gt;

&lt;p&gt;The same shape holds wherever a line is a row of values with thin separators. Access logs and VPC flow logs are in that class, so expect them near the low end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compaction and compression
&lt;/h2&gt;

&lt;p&gt;A general-purpose compressor can beat these numbers on log text. Compact is built for a different property: the reduced stream stays searchable, line by line, inside the platform.&lt;/p&gt;

&lt;p&gt;A compact event sits in the platform as a row, so a query filters on what is stored in the clear (the container, the template hash, the raw encoded text), and expand runs last, on the rows that survive the filter. Gzip applied to the same stream leaves a blob that answers no search until the whole file is unzipped. That read path is built per platform, an Elasticsearch plugin at the Lucene layer or a Splunk app at search time.&lt;/p&gt;

&lt;p&gt;The other difference is position: compact runs in the sidecar, so the reduction takes effect before the platform meters ingest and before the indexer sizes its work. An at-rest codec runs after all of that, once the platform has already counted and indexed the original bytes. Running gzip on the transport changes nothing either, because platforms meter the log data itself and the transfer encoding is invisible to the bill.&lt;/p&gt;

&lt;p&gt;That reduction reaches the bill where a platform prices by log volume. Splunk licenses by the volume ingested per day, so the reduction comes off the metered quantity directly.&lt;/p&gt;

&lt;p&gt;A self-managed store pays for the bytes it holds, but it also compresses what it holds, so the wire reduction does not carry to disk as-is. The disk number is whatever an in-place measurement says, and &lt;a href="https://www.log10x.com/blog/cutting-elasticsearch-log-storage/?utm_source=dev.to&amp;amp;utm_medium=article&amp;amp;utm_campaign=cohort1"&gt;inside Elasticsearch&lt;/a&gt; that measurement came out at roughly half the on-disk size for typical log bodies. A platform that prices by event count works on a different axis, and compact is not the tool there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round-trip check
&lt;/h2&gt;

&lt;p&gt;A reduction is only worth trusting if expand returns exactly what came in, so here is that check on two of the datasets: encode a file, concatenate the template dictionary and the encoded stream into one compact file, decode that file, and compare the result to the original. On both, expand returns the log text unchanged, with two exceptions: line endings come back as LF, and a file that does not end in a newline gets one.&lt;/p&gt;

&lt;p&gt;The 215 MB OpenTelemetry stream returns all 197,430 lines byte-identical. The full Apache LogHub set, run the same way, returns all 56,482 lines identical.&lt;/p&gt;

&lt;p&gt;The decode pass reproduces the original from the two files the encode pass wrote, without a key or any extra state. The decode command below reads &lt;a href="https://raw.githubusercontent.com/log-10x/benchmarks/main/drain3-vs-log10x/tenx-decode.config.yaml" rel="noopener noreferrer"&gt;tenx-decode.config.yaml&lt;/a&gt;, published in the benchmark harness at &lt;a href="https://github.com/log-10x/benchmarks/tree/main/drain3-vs-log10x" rel="noopener noreferrer"&gt;github.com/log-10x/benchmarks&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &amp;lt;out&amp;gt;/templates.json &amp;lt;out&amp;gt;/encoded.log &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &amp;lt;out&amp;gt;/compact.log
docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;INPUT_FILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/out/compact.log &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;OUTPUT_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/out &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; &amp;lt;out&amp;gt;:/out &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; &amp;lt;asset&amp;gt;/tenx-decode.config.yaml:/cfg/tenx-decode.config.yaml:ro &lt;span class="se"&gt;\&lt;/span&gt;
  log10x/pipeline-10x:1.1.133 @/cfg/tenx-decode.config.yaml
&lt;span class="c"&gt;# decoded.log matches the original, line for line&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Reproducing a row
&lt;/h2&gt;

&lt;p&gt;The datasets are public: the 215 MB stream is a capture of the OpenTelemetry demo's Kubernetes output, &lt;a href="https://log10x-public-assets.s3.amazonaws.com/samples/otel-k8s/large/input/otel-sample.log" rel="noopener noreferrer"&gt;published&lt;/a&gt; in the log10x public-assets bucket, and the LogHub full datasets are on &lt;a href="https://zenodo.org/records/3227177" rel="noopener noreferrer"&gt;Zenodo&lt;/a&gt;. Each run is one container invocation. The encode command reads &lt;a href="https://raw.githubusercontent.com/log-10x/benchmarks/main/drain3-vs-log10x/tenx-encode.config.yaml" rel="noopener noreferrer"&gt;tenx-encode.config.yaml&lt;/a&gt;, published beside the decode config in the same benchmark harness:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;INPUT_FILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/in/events.log &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;OUTPUT_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/out &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; &amp;lt;out&amp;gt;:/out &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; &amp;lt;input&amp;gt;:/in/events.log:ro &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; &amp;lt;asset&amp;gt;/tenx-encode.config.yaml:/cfg/tenx-encode.config.yaml:ro &lt;span class="se"&gt;\&lt;/span&gt;
  log10x/pipeline-10x:1.1.133 @/cfg/tenx-encode.config.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here &lt;code&gt;&amp;lt;input&amp;gt;&lt;/code&gt; is the dataset file, &lt;code&gt;&amp;lt;out&amp;gt;&lt;/code&gt; is a writable output directory, and &lt;code&gt;&amp;lt;asset&amp;gt;&lt;/code&gt; is the directory holding the config. The pass writes the encoded events to &lt;code&gt;encoded.log&lt;/code&gt; and the template dictionary to &lt;code&gt;templates.json&lt;/code&gt;. Sum the two file sizes against the input and the table above reproduces.&lt;/p&gt;

&lt;p&gt;Pull the image, run the command on a day of output from one cluster, and read the reduction from the two files it writes.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I work at log10x, which builds the engine measured here. The datasets and the image are public and linked above so every row can be checked independently. This post was drafted with AI assistance and edited by me.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>logging</category>
      <category>observability</category>
      <category>benchmarks</category>
      <category>devops</category>
    </item>
    <item>
      <title>Cutting Elasticsearch log storage roughly in half without changing queries</title>
      <dc:creator>Dor Levi</dc:creator>
      <pubDate>Mon, 20 Jul 2026 13:31:34 +0000</pubDate>
      <link>https://dev.to/log10x/cutting-elasticsearch-log-storage-roughly-in-half-without-changing-queries-3e2g</link>
      <guid>https://dev.to/log10x/cutting-elasticsearch-log-storage-roughly-in-half-without-changing-queries-3e2g</guid>
      <description>&lt;p&gt;Most of a log index is the same format strings stored millions of times. 10x encodes logs in the forwarding pipeline, before they reach Elasticsearch, shipping each format string once as a template and each event as the template's hash plus the values that changed. On a &lt;a href="https://github.com/log-10x/config/releases/tag/otel-sample-v1" rel="noopener noreferrer"&gt;public 200 MB sample&lt;/a&gt;, that cut the on-disk Elasticsearch index from 28.6 MB to 13.8 MB, lossless.&lt;/p&gt;

&lt;p&gt;L1ES is the plugin that makes existing text queries work unchanged over that form and puts the original line back before Kibana shows it. The plugin is MIT licensed at &lt;a href="https://github.com/log-10x/elasticsearch-plugin" rel="noopener noreferrer"&gt;github.com/log-10x/elasticsearch-plugin&lt;/a&gt;. The 10x engine that writes the form is not in that repo; it runs on the built-in Free tier on up to 10 nodes, with no licence token. The templates sit in a plain Elasticsearch index, though, and standalone decoders reproduce every line from them.&lt;/p&gt;

&lt;p&gt;The decoders that read the compact form back are MIT licensed: &lt;code&gt;com.log10x:log10x-decoder-core&lt;/code&gt; on Maven Central, with a CLI beside it, the JavaScript decoder on GitHub, and the format specification in &lt;code&gt;log10x-format&lt;/code&gt;. Reading the data back needs no engine and no licence.&lt;/p&gt;

&lt;h2&gt;
  
  
  How an event is stored
&lt;/h2&gt;

&lt;p&gt;One event from the &lt;a href="https://github.com/open-telemetry/opentelemetry-demo" rel="noopener noreferrer"&gt;OpenTelemetry demo&lt;/a&gt;'s accounting service arrives in Elasticsearch like this:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{"stream":"stdout","log":"Accounting service started","docker":{"container_id":"80d793bf70a5dbe9408a516aa34542972497a8fdc0909b59c3c4957000c211a7"},"kubernetes":{"container_name":"accounting","namespace_name":"default","pod_name":"accounting-6f7cb8cd4d-hnqb4","container_image":"ghcr.io/open-telemetry/demo:2.1.3-accounting",...,"pod_ip":"192.168.33.161","host":"ip-192-168-42-205.ec2.internal",...}}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;The raw event is 923 bytes, the envelope's middle trimmed for width.&lt;/p&gt;

&lt;p&gt;Little in that line is unique to the event. The JSON keys and the fixed message words form its template, the full JSON skeleton with &lt;code&gt;$&lt;/code&gt; where values go, stored once in a separate template index. The data index stores the event as the template's hash plus its values:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;~3;&amp;amp;4e;iuzQ,80d793bf70a5dbe9408a516aa34542972497a8fdc0909b59c3c4957000c211a7,accounting,6f7cb8cd4d,hnqb4,ghcr,2,1,3,3c3e...3418,308010eb,c129,-4320,a5ca,1ad8becbedda,192,168,33,161,-192,-168,-42,-205,accounting,accounting,...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;The same event in compact form is 349 bytes, trimmed the same way. The leading &lt;code&gt;~&lt;/code&gt; marks an encoded event, &lt;code&gt;3;&amp;amp;4e;iuzQ&lt;/code&gt; is the template hash, and everything after the first comma is the values, in template order.&lt;/p&gt;

&lt;p&gt;The 10x engine derives the templates ahead of time from the source environment, so the hashes hold on every node sharing the pinned library and engine version, for the lines that library covers. A template learned from runtime data by tools like &lt;a href="https://github.com/logpai/Drain3" rel="noopener noreferrer"&gt;Drain&lt;/a&gt; is a running summary of what one instance saw, so the same line can carry different templates on different nodes. A hash of the template inherits that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why text search breaks
&lt;/h2&gt;

&lt;p&gt;To Elasticsearch the two indices are unrelated, so a match query on the data index sees only hashes and values. Every word that came from a template's fixed text is missing there. A search for &lt;code&gt;started&lt;/code&gt; returns nothing, and no mapping change fixes that.&lt;/p&gt;

&lt;p&gt;Decoding every event at ingest restores search but erases the disk saving. A rewriting proxy in front of the cluster can edit query JSON, though it cannot reach into a Lucene segment from the REST API. So L1ES is a native plugin inside the Elasticsearch JVM, at the Lucene layer, where it can both rewrite the query and check a document inside its segment.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the plugin answers a query
&lt;/h2&gt;

&lt;p&gt;When a search arrives, &lt;a href="https://github.com/log-10x/elasticsearch-plugin/blob/main/src/main/java/com/log10x/l1es/query/L1esQueryRewriter.java" rel="noopener noreferrer"&gt;&lt;code&gt;L1esQueryRewriter\&lt;/code&gt;&lt;/a&gt; walks the query tree and swaps each text query node (&lt;code&gt;match&lt;/code&gt;, &lt;code&gt;match_phrase&lt;/code&gt;, &lt;code&gt;multi_match&lt;/code&gt;) for an L1ES-aware version, while compound nodes (&lt;code&gt;bool&lt;/code&gt;, &lt;code&gt;constant_score&lt;/code&gt;, &lt;code&gt;boosting&lt;/code&gt;, &lt;code&gt;dis_max&lt;/code&gt;) recurse and everything else passes through. Each rewritten node then sorts the candidate templates into three states. A query token is satisfied by a word in the template's fixed text or by a value in a &lt;code&gt;$&lt;/code&gt; slot. MUST_MATCH means the fixed text already carries every word the query asked for. CANNOT_MATCH means one of those words is missing from a template that has no &lt;code&gt;$&lt;/code&gt; slots to hold it. MIGHT_MATCH is the rest, where the missing word could turn up in a value.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwww.log10x.com%2Fblog%2Fcontent%2Fimages%2F2026%2F06%2Fquerying-compressed-logs-elasticsearch-l1es-diagram.svg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwww.log10x.com%2Fblog%2Fcontent%2Fimages%2F2026%2F06%2Fquerying-compressed-logs-elasticsearch-l1es-diagram.svg" alt="Two yes/no tests decide a template's state: are all query tokens in its static text, and does it have variables. All tokens present means MUST_MATCH either way; otherwise CANNOT_MATCH when there are no variables, or MIGHT_MATCH, the only state that checks a document, when a variable could hold the missing token." width="720" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Only MIGHT_MATCH documents pay a per-document check. &lt;a href="https://github.com/log-10x/elasticsearch-plugin/blob/main/src/main/java/com/log10x/l1es/query/wrap/L1esScorerWrapper.java" rel="noopener noreferrer"&gt;&lt;code&gt;L1esScorerWrapper&lt;/code&gt;&lt;/a&gt; wraps the inner Lucene scorer in a &lt;a href="https://lucene.apache.org/core/9_12_0/core/org/apache/lucene/search/TwoPhaseIterator.html" rel="noopener noreferrer"&gt;&lt;code&gt;TwoPhaseIterator&lt;/code&gt;&lt;/a&gt; whose exact check decodes the candidate and tests the query against the original line. After scoring, &lt;a href="https://github.com/log-10x/elasticsearch-plugin/blob/main/src/main/java/com/log10x/l1es/fetch/L1esFetchSubPhase.java" rel="noopener noreferrer"&gt;&lt;code&gt;L1esFetchSubPhase&lt;/code&gt;&lt;/a&gt; rebuilds the original line into &lt;code&gt;_source&lt;/code&gt;, so Kibana Discover shows the log as printed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The optional template-hash field
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/log-10x/elasticsearch-plugin/blob/main/src/main/java/com/log10x/l1es/query/match/query/L1esMatchQuery.java" rel="noopener noreferrer"&gt;&lt;code&gt;L1esMatchQuery\&lt;/code&gt;&lt;/a&gt; first checks whether the data index carries a keyword field, &lt;code&gt;l1x_tid&lt;/code&gt;, holding each document's template hash. With it, classification runs against an in-memory snapshot of the templates, with no Elasticsearch round-trip. Without it, the plugin queries the template index directly, which is slower but correct.&lt;/p&gt;

&lt;p&gt;I could have made the field mandatory and deleted the slower path. I did not, because a plugin that demands a mapping change before it does anything is blocked until a reindex is scheduled. Any index that already holds encoded data can install the plugin and search today. Adding &lt;code&gt;l1x_tid&lt;/code&gt; later enables the fast path on the next search, with no restart. The price is a second code path that has to stay correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the plugin does not handle
&lt;/h2&gt;

&lt;p&gt;Single-token queries are answered at the template level, so searching &lt;code&gt;error&lt;/code&gt; returns every event of any template whose fixed text carries that word, even when the event is about something else. The extra hits are over-matches, adding events rather than altering or dropping them. Multi-token AND and phrase queries stay precise, because the per-document check verifies the decoded line.&lt;/p&gt;

&lt;p&gt;Query types the rewriter does not cover (&lt;code&gt;term&lt;/code&gt;, &lt;code&gt;wildcard&lt;/code&gt;, &lt;code&gt;regexp&lt;/code&gt;, and &lt;code&gt;query_string&lt;/code&gt;, Kibana's Lucene syntax mode) see the encoded text directly, matching values but missing words that come from a template's fixed text, and so does anything else that reads the field, such as an aggregation or a runtime field. KQL compiles to the three rewritten query types, a field-less search becoming &lt;code&gt;multi_match&lt;/code&gt;, so most Kibana usage takes the rewritten path. If the per-document check throws, the plugin keeps the document.&lt;/p&gt;

&lt;p&gt;Relevance scores change, because a document matched through its template scores as a filter hit rather than BM25 over the original line. Log views sort by time, though. Highlighting runs against the stored encoded text without being rebuilt, so matched words are not marked in results. Decode work moves to query time as well, which costs more CPU than the same queries over raw text.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the index measured
&lt;/h2&gt;

&lt;p&gt;I indexed the 200 MB sample into Elasticsearch 8.17.0 twice, raw (one &lt;code&gt;message&lt;/code&gt; text field per event) and compact, both with &lt;code&gt;best_compression&lt;/code&gt;, one segment, and no replicas. A mapping that parses fields changes both sides and is not measured here. Each side keeps enough to reproduce every original line.&lt;/p&gt;

&lt;p&gt;The raw index measured 28.6 MB across 197,430 documents. The compact pair measured 13.2 MB of data plus 0.6 MB of templates, 13.8 MB total, across 159,454 documents, because multi-line events merge into one during encoding. Decoding returned all 197,430 original lines byte-identical when the sample was round-tripped. The boundary sits one step earlier, at the file read, where bytes that are not valid UTF-8 are substituted before the engine tokenizes, the same substitution any UTF-8 log reader makes. The template index is part of the data. Cluster snapshots must include it, because events decode only against their templates.&lt;/p&gt;

&lt;p&gt;On 8.17.0, logsdb mode with synthetic &lt;code&gt;_source&lt;/code&gt; on the same raw sample reclaimed under 4%, 28.7 MB to 27.7 MB. A block codec strips repetition only inside its window, while storing the template once takes it out of the data entirely. On Elasticsearch 9.5.5, with the record parsed into fields, logsdb with &lt;code&gt;pattern_text&lt;/code&gt; sorted on its template id held &lt;a href="https://github.com/log-10x/benchmarks/tree/main/elastic-store-four-ways" rel="noopener noreferrer"&gt;the same sample&lt;/a&gt; in 34.7% of the raw index's store, against 53.3% for the compact form on logsdb.&lt;/p&gt;

&lt;p&gt;The raw sample, the encoded form, and the templates are published in &lt;a href="https://github.com/log-10x/config/releases/tag/otel-sample-v1" rel="noopener noreferrer"&gt;one release&lt;/a&gt;. Indexing the files reproduces the numbers. Every size in this post is on-disk index size.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building for one version at a time
&lt;/h2&gt;

&lt;p&gt;Elasticsearch loads a plugin only when it was built for the exact running version, and the descriptor pins it. Version coverage is therefore build-system work. One source tree targets an Elasticsearch or OpenSearch version through one Gradle property, the zip is named for the version it targets, and the OpenSearch variant is a subproject of the same tree that behaves identically, registered as a search pipeline. 8.17.0 and 2.19.0 are the versions the repo pins, and the &lt;a href="https://github.com/log-10x/elasticsearch-plugin/releases/latest" rel="noopener noreferrer"&gt;latest release&lt;/a&gt; carries a zip for each.&lt;/p&gt;

&lt;p&gt;The plugin requires Java 17 or later, and standalone &lt;a href="https://github.com/log-10x/log10x-decoder-java" rel="noopener noreferrer"&gt;Java&lt;/a&gt; and &lt;a href="https://github.com/log-10x/log10x-decoder-js" rel="noopener noreferrer"&gt;JavaScript&lt;/a&gt; decoders read the compact form outside Elasticsearch. On Elasticsearch 9.3 and later, Elastic's &lt;code&gt;pattern_text&lt;/code&gt; field type stores message templates natively.&lt;/p&gt;




&lt;p&gt;Related: &lt;a href="https://www.log10x.com/blog/why-log-clustering-keeps-drifting/" rel="noopener noreferrer"&gt;why the template hash stays stable&lt;/a&gt;, &lt;a href="https://www.log10x.com/blog/stop-extracting-log-fields-per-event/" rel="noopener noreferrer"&gt;what else attaches to a template&lt;/a&gt;, &lt;a href="https://www.log10x.com/blog/where-log-structure-actually-lives/" rel="noopener noreferrer"&gt;where the symbol library comes from&lt;/a&gt;, &lt;a href="https://www.log10x.com/blog/cutting-splunk-log-storage/" rel="noopener noreferrer"&gt;the Splunk-side equivalent&lt;/a&gt;, and &lt;a href="https://www.log10x.com/blog/clickhouse-offload-merge-table/" rel="noopener noreferrer"&gt;reading ClickHouse logs from the table and the bucket&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>elasticsearch</category>
      <category>logging</category>
      <category>lucene</category>
      <category>java</category>
    </item>
  </channel>
</rss>
