{
  "@context": "https://schema.org",
  "@id": "https://aggr.aymericbeaumet.com/items/dev-37signals-com/2026-10-19-parsing-json-structured-docker-logs-with-the-opentelemetry/#webpage",
  "@type": [
    "WebPage",
    "ArchiveComponent"
  ],
  "articleBody": "How we use the filelog receiver’s operator chain to parse, recombine, and route JSON-structured log lines from Docker containers.\n\nWe deploy our Rails apps with [Kamal](https://dev.37signals.com/kamal-2/), which runs them as Docker containers. Alongside the [Prometheus metrics collection](https://dev.37signals.com/kamal-prometheus/) we set up earlier, we run the [OpenTelemetry Collector](https://opentelemetry.io/docs/collector/) on each host to ship logs to [Loki](https://grafana.com/oss/loki/). Our Rails apps emit one JSON object per request to stdout: controller, status code, duration and the full Rails log output, all in a single line. Docker’s `json-file` logging driver then wraps each of those lines in *another* layer of JSON. Here’s how we use the collector’s `filelog` receiver to unwrap and parse them.\n\n* * *\n\n## No built-in Docker log receiver\n\nThe OpenTelemetry Collector doesn’t have a dedicated Docker log receiver. There’s a [dockerstats receiver](https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/receiver/dockerstatsreceiver) for container metrics, but nothing equivalent for logs. The `filelog` receiver’s `container` operator *can* parse Docker’s `json-file` format, but it only unwraps the outer JSON envelope. It recombines split lines for the CRI format used by containerd and CRI-O, but not for Docker’s format, and it doesn’t handle non-JSON content mixed into your log stream. Most Rails apps write some plain text to stdout too.\n\nSo collecting Docker container logs with the OTel Collector means putting together a chain of operators yourself. It’s not hard once you know the pieces, but it’s not obvious either.\n\n* * *\n\n## The problem: JSON inside JSON\n\nDocker’s `json-file` driver wraps every line your app emits in a JSON envelope:\n\n```json\n{\"log\":\"{\\\"@timestamp\\\":\\\"2026-03-14T12:00:00Z\\\",\\\"rails\\\":{\\\"controller\\\":\\\"PostsController\\\"},\\\"http\\\":{\\\"response\\\":{\\\"status_code\\\":200}},\\\"original\\\":\\\"Started GET /posts...\\\"}\\n\",\"stream\":\"stdout\",\"time\":\"2026-03-14T12:00:00.123Z\"}\n```\n\nThe `log` field contains your app’s JSON as an escaped string. And if your app’s line is longer than 16KB, Docker splits it across multiple records, each one a separate JSON object with part of the `log` value. Only the last part ends with a newline. That newline is the only way to tell a partial record from a complete one.\n\n* * *\n\n## The operator chain\n\nThe filelog receiver lets you define a chain of operators that process log entries in sequence. Here’s our setup:\n\n```yaml\nreceivers:\n  filelog:\n    include: [/var/lib/docker/containers/*/*-json.log]\n    start_at: end\n    operators:\n      - type: container\n        add_metadata_from_filepath: false\n\n      - type: filter\n        expr: 'body matches \"^[^{]\" and body matches \"[^}]\\\\n$\"'\n\n      - type: recombine\n        combine_field: body\n        source_identifier: attributes[\"log.file.name\"]\n        combine_with: \"\"\n        is_last_entry: body matches \"\\n$\"\n        max_unmatched_batch_size: 0\n\n      - type: json_parser\n        if: body matches \"^\\\\{\"\n        parse_from: body\n        parse_to: attributes\n        on_error: send\n```\n\nLet’s walk through each step.\n\n### 1\\. `container`: unwrap the Docker envelope\n\nThe `container` operator parses Docker’s `json-file` format (and CRI format too). It extracts the `log` field as the body, `stream` as an attribute, and `time` as the timestamp. After this step, the body is your app’s raw log line, or part of it, but still a string, not yet parsed.\n\n`add_metadata_from_filepath: false` turns off the Kubernetes metadata the operator reads from a pod’s log file path. Docker’s log paths don’t contain that metadata, and with the option on, the operator logs an error for every record unless the receiver also adds the file path (`include_file_path: true`).\n\n### 2\\. `filter`: drop non-JSON lines\n\nNot everything your app writes to stdout is JSON. Puma access logs, Ruby warnings, and middleware timing lines (like `Rack handled in 18.27ms. 0 GCs.`) are plain text. We don’t want these in Loki, and some of our pipelines pick up lines by container name alone, so we drop them here.\n\nWe need to be careful because of how Docker splits long lines. The first chunk of a split line starts with `{` but the middle chunks won’t, and only the final chunk ends with a newline. We can’t just drop everything that doesn’t look like JSON, or we’d lose pieces of split log lines.\n\nThe filter drops lines that start with a non-`{` character **and** don’t end with `}\\n`:\n\n```plaintext\nbody matches \"^[^{]\" and body matches \"[^}]\\\\n$\"\n```\n\nA complete JSON line starts with `{`, so the first condition is false and it passes through. A middle chunk of a split JSON line has no newline at the end, so the second condition is false and it also passes through. Only standalone plain-text lines match both conditions and get dropped.\n\n### 3\\. `recombine`: reassemble split JSON\n\nDocker’s `json-file` driver splits log lines at 16KB boundaries. A single JSON log from your app might become two or three Docker log entries. The `container` operator handles this for CRI format but not for Docker’s `json-file` format, so we do it ourselves.\n\nThe recombine operator concatenates consecutive entries until it sees one whose body ends with a newline, which is how Docker marks the end of a line:\n\n```yaml\n- type: recombine\n  combine_field: body\n  source_identifier: attributes[\"log.file.name\"]\n  combine_with: \"\"\n  is_last_entry: body matches \"\\n$\"\n  max_unmatched_batch_size: 0\n```\n\n`source_identifier` ensures we only recombine entries from the same log file, so logs from different containers don’t get mixed up. That’s important when Kamal runs multiple containers on the same host during deploys. `combine_with: \"\"` concatenates without adding any separator, since Docker’s splits are at arbitrary byte boundaries.\n\n`max_unmatched_batch_size: 0` turns off a default limit of 100 entries. Without it, recombine sends on what it has after 100 partial records, so any line over 1.6MB gets cut in two.\n\n### 4\\. `json_parser`: parse the app JSON\n\nNow the body contains the complete, reassembled JSON string from your app. The `json_parser` operator parses it and writes the resulting fields into the log record’s attributes:\n\n```yaml\n- type: json_parser\n  if: body matches \"^\\\\{\"\n  parse_from: body\n  parse_to: attributes\n  on_error: send\n```\n\nAfter this, `attributes[\"rails\"][\"controller\"]` is `\"PostsController\"`, `attributes[\"http\"][\"response\"][\"status_code\"]` is `200`, and so on. The log record is now fully structured and ready for routing, transformation, and export.\n\nThe `if` skips anything that doesn’t start with `{`, so the parser doesn’t log an error for every piece of plain text that reaches it. `on_error: send` passes a record that fails to parse on with its raw body, and the collector logs the error. We count those errors in the collector’s own logs, and that’s how we found the two problems below.\n\n* * *\n\n## Two things we got wrong\n\nOur first version of this chain ended a line on `}\\n` rather than `\\n`, and left `max_unmatched_batch_size` at its default. Both lost log lines.\n\nWhen a line is an exact multiple of 16KB before its newline, Docker’s split falls between the closing `}` and the `\\n`. The last record is then just `\"\\n\"`. Neither record matched `}\\n$`, so recombine kept going and joined the next log line on as well. That gave us two JSON objects in one body, `invalid character '{' after top-level value` from the parser, and two lost lines. It only happens to about 1 in 16,384 long lines, but on Basecamp that was around 320 times an hour.\n\nThe 100-record limit cut lines over 1.6MB in two. The first part failed to parse with `unexpected end of JSON input` and the rest was dropped. That was about 110 lines every 6 hours on Basecamp.\n\nWe now test the receiver config by running the collector against sample Docker log files, including a line split just before its newline and a line in more than 100 records, and checking which records come out.\n\n* * *\n\n## What comes next\n\nWith structured attributes on each record, you can use the [routing connector](https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/connector/routingconnector) to send different log formats to different pipelines, and the [transform processor](https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/processor/transformprocessor) to normalize fields, set severity levels, and enrich logs before exporting them.\n\nThe operator chain runs *inside* the receiver, before logs enter the pipeline. Getting the unwrap → filter → recombine → parse sequence right here means your processors only ever see clean, fully-parsed log records.\n\n* * *\n\n- [Other posts in the “On-prem observability” series](https://dev.37signals.com/series/on-prem-observability/)\n",
  "authors": [
    "Lewis Buckley"
  ],
  "captured_at": "2026-10-19T19:14:52.283302801+00:00",
  "category": "programming",
  "content_html": "<p>How we use the filelog receiver’s operator chain to parse, recombine, and route JSON-structured log lines from Docker containers.</p>\n<p>We deploy our Rails apps with <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://dev.37signals.com/kamal-2/\">Kamal</a>, which runs them as Docker containers. Alongside the <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://dev.37signals.com/kamal-prometheus/\">Prometheus metrics collection</a> we set up earlier, we run the <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://opentelemetry.io/docs/collector/\">OpenTelemetry Collector</a> on each host to ship logs to <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://grafana.com/oss/loki/\">Loki</a>. Our Rails apps emit one JSON object per request to stdout: controller, status code, duration and the full Rails log output, all in a single line. Docker’s <code>json-file</code> logging driver then wraps each of those lines in <em>another</em> layer of JSON. Here’s how we use the collector’s <code>filelog</code> receiver to unwrap and parse them.</p>\n<hr />\n<h2>No built-in Docker log receiver</h2>\n<p>The OpenTelemetry Collector doesn’t have a dedicated Docker log receiver. There’s a <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/receiver/dockerstatsreceiver\">dockerstats receiver</a> for container metrics, but nothing equivalent for logs. The <code>filelog</code> receiver’s <code>container</code> operator <em>can</em> parse Docker’s <code>json-file</code> format, but it only unwraps the outer JSON envelope. It recombines split lines for the CRI format used by containerd and CRI-O, but not for Docker’s format, and it doesn’t handle non-JSON content mixed into your log stream. Most Rails apps write some plain text to stdout too.</p>\n<p>So collecting Docker container logs with the OTel Collector means putting together a chain of operators yourself. It’s not hard once you know the pieces, but it’s not obvious either.</p>\n<hr />\n<h2>The problem: JSON inside JSON</h2>\n<p>Docker’s <code>json-file</code> driver wraps every line your app emits in a JSON envelope:</p>\n<pre><code class=\"language-json\"><span class=\"code-snippet\" data-language=\"JSON\"><span class=\"syntax-source syntax-json\"><span class=\"syntax-meta syntax-structure syntax-dictionary syntax-json\"><span class=\"syntax-punctuation syntax-section syntax-dictionary syntax-begin syntax-json\">{</span><span class=\"syntax-meta syntax-structure syntax-dictionary syntax-key syntax-json\"><span class=\"syntax-string syntax-quoted syntax-double syntax-json\"><span class=\"syntax-punctuation syntax-definition syntax-string syntax-begin syntax-json\">&quot;</span>log<span class=\"syntax-punctuation syntax-definition syntax-string syntax-end syntax-json\">&quot;</span></span></span><span class=\"syntax-meta syntax-structure syntax-dictionary syntax-value syntax-json\"><span class=\"syntax-punctuation syntax-separator syntax-dictionary syntax-key-value syntax-json\">:</span><span class=\"syntax-string syntax-quoted syntax-double syntax-json\"><span class=\"syntax-punctuation syntax-definition syntax-string syntax-begin syntax-json\">&quot;</span>{<span class=\"syntax-constant syntax-character syntax-escape syntax-json\">\\&quot;</span>@timestamp<span class=\"syntax-constant syntax-character syntax-escape syntax-json\">\\&quot;</span>:<span class=\"syntax-constant syntax-character syntax-escape syntax-json\">\\&quot;</span>2026-03-14T12:00:00Z<span class=\"syntax-constant syntax-character syntax-escape syntax-json\">\\&quot;</span>,<span class=\"syntax-constant syntax-character syntax-escape syntax-json\">\\&quot;</span>rails<span class=\"syntax-constant syntax-character syntax-escape syntax-json\">\\&quot;</span>:{<span class=\"syntax-constant syntax-character syntax-escape syntax-json\">\\&quot;</span>controller<span class=\"syntax-constant syntax-character syntax-escape syntax-json\">\\&quot;</span>:<span class=\"syntax-constant syntax-character syntax-escape syntax-json\">\\&quot;</span>PostsController<span class=\"syntax-constant syntax-character syntax-escape syntax-json\">\\&quot;</span>},<span class=\"syntax-constant syntax-character syntax-escape syntax-json\">\\&quot;</span>http<span class=\"syntax-constant syntax-character syntax-escape syntax-json\">\\&quot;</span>:{<span class=\"syntax-constant syntax-character syntax-escape syntax-json\">\\&quot;</span>response<span class=\"syntax-constant syntax-character syntax-escape syntax-json\">\\&quot;</span>:{<span class=\"syntax-constant syntax-character syntax-escape syntax-json\">\\&quot;</span>status_code<span class=\"syntax-constant syntax-character syntax-escape syntax-json\">\\&quot;</span>:200}},<span class=\"syntax-constant syntax-character syntax-escape syntax-json\">\\&quot;</span>original<span class=\"syntax-constant syntax-character syntax-escape syntax-json\">\\&quot;</span>:<span class=\"syntax-constant syntax-character syntax-escape syntax-json\">\\&quot;</span>Started GET /posts...<span class=\"syntax-constant syntax-character syntax-escape syntax-json\">\\&quot;</span>}<span class=\"syntax-constant syntax-character syntax-escape syntax-json\">\\n</span><span class=\"syntax-punctuation syntax-definition syntax-string syntax-end syntax-json\">&quot;</span></span><span class=\"syntax-punctuation syntax-separator syntax-dictionary syntax-pair syntax-json\">,</span></span><span class=\"syntax-meta syntax-structure syntax-dictionary syntax-key syntax-json\"><span class=\"syntax-string syntax-quoted syntax-double syntax-json\"><span class=\"syntax-punctuation syntax-definition syntax-string syntax-begin syntax-json\">&quot;</span>stream<span class=\"syntax-punctuation syntax-definition syntax-string syntax-end syntax-json\">&quot;</span></span></span><span class=\"syntax-meta syntax-structure syntax-dictionary syntax-value syntax-json\"><span class=\"syntax-punctuation syntax-separator syntax-dictionary syntax-key-value syntax-json\">:</span><span class=\"syntax-string syntax-quoted syntax-double syntax-json\"><span class=\"syntax-punctuation syntax-definition syntax-string syntax-begin syntax-json\">&quot;</span>stdout<span class=\"syntax-punctuation syntax-definition syntax-string syntax-end syntax-json\">&quot;</span></span><span class=\"syntax-punctuation syntax-separator syntax-dictionary syntax-pair syntax-json\">,</span></span><span class=\"syntax-meta syntax-structure syntax-dictionary syntax-key syntax-json\"><span class=\"syntax-string syntax-quoted syntax-double syntax-json\"><span class=\"syntax-punctuation syntax-definition syntax-string syntax-begin syntax-json\">&quot;</span>time<span class=\"syntax-punctuation syntax-definition syntax-string syntax-end syntax-json\">&quot;</span></span></span><span class=\"syntax-meta syntax-structure syntax-dictionary syntax-value syntax-json\"><span class=\"syntax-punctuation syntax-separator syntax-dictionary syntax-key-value syntax-json\">:</span><span class=\"syntax-string syntax-quoted syntax-double syntax-json\"><span class=\"syntax-punctuation syntax-definition syntax-string syntax-begin syntax-json\">&quot;</span>2026-03-14T12:00:00.123Z<span class=\"syntax-punctuation syntax-definition syntax-string syntax-end syntax-json\">&quot;</span></span></span><span class=\"syntax-punctuation syntax-section syntax-dictionary syntax-end syntax-json\">}</span></span>\n</span></span></code></pre>\n<p>The <code>log</code> field contains your app’s JSON as an escaped string. And if your app’s line is longer than 16KB, Docker splits it across multiple records, each one a separate JSON object with part of the <code>log</code> value. Only the last part ends with a newline. That newline is the only way to tell a partial record from a complete one.</p>\n<hr />\n<h2>The operator chain</h2>\n<p>The filelog receiver lets you define a chain of operators that process log entries in sequence. Here’s our setup:</p>\n<pre><code class=\"language-yaml\"><span class=\"code-snippet\" data-language=\"YAML\"><span class=\"syntax-source syntax-yaml\"><span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">receivers</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span>\n  <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">filelog</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span>\n    <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">include</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span> <span class=\"syntax-meta syntax-flow-sequence syntax-yaml\"><span class=\"syntax-punctuation syntax-definition syntax-sequence syntax-begin syntax-yaml\">[</span><span class=\"syntax-string syntax-unquoted syntax-plain syntax-in syntax-yaml\">/var/lib/docker/containers/*/*-json.log</span><span class=\"syntax-punctuation syntax-definition syntax-sequence syntax-end syntax-yaml\">]</span></span>\n    <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">start_at</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span> <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\">end</span>\n    <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">operators</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span>\n      <span class=\"syntax-punctuation syntax-definition syntax-block syntax-sequence syntax-item syntax-yaml\">-</span> <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">type</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span> <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\">container</span>\n        <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">add_metadata_from_filepath</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span> <span class=\"syntax-constant syntax-language syntax-boolean syntax-yaml\">false</span>\n\n      <span class=\"syntax-punctuation syntax-definition syntax-block syntax-sequence syntax-item syntax-yaml\">-</span> <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">type</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span> <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\">filter</span>\n        <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">expr</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span> <span class=\"syntax-string syntax-quoted syntax-single syntax-yaml\"><span class=\"syntax-punctuation syntax-definition syntax-string syntax-begin syntax-yaml\">&#39;</span>body matches &quot;^[^{]&quot; and body matches &quot;[^}]\\\\n$&quot;<span class=\"syntax-punctuation syntax-definition syntax-string syntax-end syntax-yaml\">&#39;</span></span>\n\n      <span class=\"syntax-punctuation syntax-definition syntax-block syntax-sequence syntax-item syntax-yaml\">-</span> <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">type</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span> <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\">recombine</span>\n        <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">combine_field</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span> <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\">body</span>\n        <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">source_identifier</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span> <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\">attributes[&quot;log.file.name&quot;]</span>\n        <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">combine_with</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span> <span class=\"syntax-string syntax-quoted syntax-double syntax-yaml\"><span class=\"syntax-punctuation syntax-definition syntax-string syntax-begin syntax-yaml\">&quot;</span><span class=\"syntax-punctuation syntax-definition syntax-string syntax-end syntax-yaml\">&quot;</span></span>\n        <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">is_last_entry</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span> <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\">body matches &quot;\\n$&quot;</span>\n        <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">max_unmatched_batch_size</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span> <span class=\"syntax-constant syntax-numeric syntax-integer syntax-yaml\">0</span>\n\n      <span class=\"syntax-punctuation syntax-definition syntax-block syntax-sequence syntax-item syntax-yaml\">-</span> <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">type</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span> <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\">json_parser</span>\n        <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">if</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span> <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\">body matches &quot;^\\\\{&quot;</span>\n        <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">parse_from</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span> <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\">body</span>\n        <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">parse_to</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span> <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\">attributes</span>\n        <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">on_error</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span> <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\">send</span>\n</span></span></code></pre>\n<p>Let’s walk through each step.</p>\n<h3>1. <code>container</code>: unwrap the Docker envelope</h3>\n<p>The <code>container</code> operator parses Docker’s <code>json-file</code> format (and CRI format too). It extracts the <code>log</code> field as the body, <code>stream</code> as an attribute, and <code>time</code> as the timestamp. After this step, the body is your app’s raw log line, or part of it, but still a string, not yet parsed.</p>\n<p><code>add_metadata_from_filepath: false</code> turns off the Kubernetes metadata the operator reads from a pod’s log file path. Docker’s log paths don’t contain that metadata, and with the option on, the operator logs an error for every record unless the receiver also adds the file path (<code>include_file_path: true</code>).</p>\n<h3>2. <code>filter</code>: drop non-JSON lines</h3>\n<p>Not everything your app writes to stdout is JSON. Puma access logs, Ruby warnings, and middleware timing lines (like <code>Rack handled in 18.27ms. 0 GCs.</code>) are plain text. We don’t want these in Loki, and some of our pipelines pick up lines by container name alone, so we drop them here.</p>\n<p>We need to be careful because of how Docker splits long lines. The first chunk of a split line starts with <code>{</code> but the middle chunks won’t, and only the final chunk ends with a newline. We can’t just drop everything that doesn’t look like JSON, or we’d lose pieces of split log lines.</p>\n<p>The filter drops lines that start with a non-<code>{</code> character <strong>and</strong> don’t end with <code>}\\n</code>:</p>\n<pre><code class=\"language-plaintext\"><span class=\"code-snippet\">body matches &quot;^[^{]&quot; and body matches &quot;[^}]\\\\n$&quot;\n</span></code></pre>\n<p>A complete JSON line starts with <code>{</code>, so the first condition is false and it passes through. A middle chunk of a split JSON line has no newline at the end, so the second condition is false and it also passes through. Only standalone plain-text lines match both conditions and get dropped.</p>\n<h3>3. <code>recombine</code>: reassemble split JSON</h3>\n<p>Docker’s <code>json-file</code> driver splits log lines at 16KB boundaries. A single JSON log from your app might become two or three Docker log entries. The <code>container</code> operator handles this for CRI format but not for Docker’s <code>json-file</code> format, so we do it ourselves.</p>\n<p>The recombine operator concatenates consecutive entries until it sees one whose body ends with a newline, which is how Docker marks the end of a line:</p>\n<pre><code class=\"language-yaml\"><span class=\"code-snippet\" data-language=\"YAML\"><span class=\"syntax-source syntax-yaml\"><span class=\"syntax-punctuation syntax-definition syntax-block syntax-sequence syntax-item syntax-yaml\">-</span> <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">type</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span> <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\">recombine</span>\n  <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">combine_field</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span> <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\">body</span>\n  <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">source_identifier</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span> <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\">attributes[&quot;log.file.name&quot;]</span>\n  <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">combine_with</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span> <span class=\"syntax-string syntax-quoted syntax-double syntax-yaml\"><span class=\"syntax-punctuation syntax-definition syntax-string syntax-begin syntax-yaml\">&quot;</span><span class=\"syntax-punctuation syntax-definition syntax-string syntax-end syntax-yaml\">&quot;</span></span>\n  <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">is_last_entry</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span> <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\">body matches &quot;\\n$&quot;</span>\n  <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">max_unmatched_batch_size</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span> <span class=\"syntax-constant syntax-numeric syntax-integer syntax-yaml\">0</span>\n</span></span></code></pre>\n<p><code>source_identifier</code> ensures we only recombine entries from the same log file, so logs from different containers don’t get mixed up. That’s important when Kamal runs multiple containers on the same host during deploys. <code>combine_with: &quot;&quot;</code> concatenates without adding any separator, since Docker’s splits are at arbitrary byte boundaries.</p>\n<p><code>max_unmatched_batch_size: 0</code> turns off a default limit of 100 entries. Without it, recombine sends on what it has after 100 partial records, so any line over 1.6MB gets cut in two.</p>\n<h3>4. <code>json_parser</code>: parse the app JSON</h3>\n<p>Now the body contains the complete, reassembled JSON string from your app. The <code>json_parser</code> operator parses it and writes the resulting fields into the log record’s attributes:</p>\n<pre><code class=\"language-yaml\"><span class=\"code-snippet\" data-language=\"YAML\"><span class=\"syntax-source syntax-yaml\"><span class=\"syntax-punctuation syntax-definition syntax-block syntax-sequence syntax-item syntax-yaml\">-</span> <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">type</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span> <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\">json_parser</span>\n  <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">if</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span> <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\">body matches &quot;^\\\\{&quot;</span>\n  <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">parse_from</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span> <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\">body</span>\n  <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">parse_to</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span> <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\">attributes</span>\n  <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\"><span class=\"syntax-entity syntax-name syntax-tag syntax-yaml\">on_error</span></span><span class=\"syntax-punctuation syntax-separator syntax-key-value syntax-mapping syntax-yaml\">:</span> <span class=\"syntax-string syntax-unquoted syntax-plain syntax-out syntax-yaml\">send</span>\n</span></span></code></pre>\n<p>After this, <code>attributes[&quot;rails&quot;][&quot;controller&quot;]</code> is <code>&quot;PostsController&quot;</code>, <code>attributes[&quot;http&quot;][&quot;response&quot;][&quot;status_code&quot;]</code> is <code>200</code>, and so on. The log record is now fully structured and ready for routing, transformation, and export.</p>\n<p>The <code>if</code> skips anything that doesn’t start with <code>{</code>, so the parser doesn’t log an error for every piece of plain text that reaches it. <code>on_error: send</code> passes a record that fails to parse on with its raw body, and the collector logs the error. We count those errors in the collector’s own logs, and that’s how we found the two problems below.</p>\n<hr />\n<h2>Two things we got wrong</h2>\n<p>Our first version of this chain ended a line on <code>}\\n</code> rather than <code>\\n</code>, and left <code>max_unmatched_batch_size</code> at its default. Both lost log lines.</p>\n<p>When a line is an exact multiple of 16KB before its newline, Docker’s split falls between the closing <code>}</code> and the <code>\\n</code>. The last record is then just <code>&quot;\\n&quot;</code>. Neither record matched <code>}\\n$</code>, so recombine kept going and joined the next log line on as well. That gave us two JSON objects in one body, <code>invalid character '{' after top-level value</code> from the parser, and two lost lines. It only happens to about 1 in 16,384 long lines, but on Basecamp that was around 320 times an hour.</p>\n<p>The 100-record limit cut lines over 1.6MB in two. The first part failed to parse with <code>unexpected end of JSON input</code> and the rest was dropped. That was about 110 lines every 6 hours on Basecamp.</p>\n<p>We now test the receiver config by running the collector against sample Docker log files, including a line split just before its newline and a line in more than 100 records, and checking which records come out.</p>\n<hr />\n<h2>What comes next</h2>\n<p>With structured attributes on each record, you can use the <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/connector/routingconnector\">routing connector</a> to send different log formats to different pipelines, and the <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/processor/transformprocessor\">transform processor</a> to normalize fields, set severity levels, and enrich logs before exporting them.</p>\n<p>The operator chain runs <em>inside</em> the receiver, before logs enter the pipeline. Getting the unwrap → filter → recombine → parse sequence right here means your processors only ever see clean, fully-parsed log records.</p>\n<hr />\n<ul>\n<li><a target=\"_blank\" rel=\"noopener noreferrer\" href=\"https://dev.37signals.com/series/on-prem-observability/\">Other posts in the “On-prem observability” series</a></li>\n</ul>\n",
  "content_markdown": "How we use the filelog receiver’s operator chain to parse, recombine, and route JSON-structured log lines from Docker containers.\n\nWe deploy our Rails apps with [Kamal](https://dev.37signals.com/kamal-2/), which runs them as Docker containers. Alongside the [Prometheus metrics collection](https://dev.37signals.com/kamal-prometheus/) we set up earlier, we run the [OpenTelemetry Collector](https://opentelemetry.io/docs/collector/) on each host to ship logs to [Loki](https://grafana.com/oss/loki/). Our Rails apps emit one JSON object per request to stdout: controller, status code, duration and the full Rails log output, all in a single line. Docker’s `json-file` logging driver then wraps each of those lines in *another* layer of JSON. Here’s how we use the collector’s `filelog` receiver to unwrap and parse them.\n\n* * *\n\n## No built-in Docker log receiver\n\nThe OpenTelemetry Collector doesn’t have a dedicated Docker log receiver. There’s a [dockerstats receiver](https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/receiver/dockerstatsreceiver) for container metrics, but nothing equivalent for logs. The `filelog` receiver’s `container` operator *can* parse Docker’s `json-file` format, but it only unwraps the outer JSON envelope. It recombines split lines for the CRI format used by containerd and CRI-O, but not for Docker’s format, and it doesn’t handle non-JSON content mixed into your log stream. Most Rails apps write some plain text to stdout too.\n\nSo collecting Docker container logs with the OTel Collector means putting together a chain of operators yourself. It’s not hard once you know the pieces, but it’s not obvious either.\n\n* * *\n\n## The problem: JSON inside JSON\n\nDocker’s `json-file` driver wraps every line your app emits in a JSON envelope:\n\n```json\n{\"log\":\"{\\\"@timestamp\\\":\\\"2026-03-14T12:00:00Z\\\",\\\"rails\\\":{\\\"controller\\\":\\\"PostsController\\\"},\\\"http\\\":{\\\"response\\\":{\\\"status_code\\\":200}},\\\"original\\\":\\\"Started GET /posts...\\\"}\\n\",\"stream\":\"stdout\",\"time\":\"2026-03-14T12:00:00.123Z\"}\n```\n\nThe `log` field contains your app’s JSON as an escaped string. And if your app’s line is longer than 16KB, Docker splits it across multiple records, each one a separate JSON object with part of the `log` value. Only the last part ends with a newline. That newline is the only way to tell a partial record from a complete one.\n\n* * *\n\n## The operator chain\n\nThe filelog receiver lets you define a chain of operators that process log entries in sequence. Here’s our setup:\n\n```yaml\nreceivers:\n  filelog:\n    include: [/var/lib/docker/containers/*/*-json.log]\n    start_at: end\n    operators:\n      - type: container\n        add_metadata_from_filepath: false\n\n      - type: filter\n        expr: 'body matches \"^[^{]\" and body matches \"[^}]\\\\n$\"'\n\n      - type: recombine\n        combine_field: body\n        source_identifier: attributes[\"log.file.name\"]\n        combine_with: \"\"\n        is_last_entry: body matches \"\\n$\"\n        max_unmatched_batch_size: 0\n\n      - type: json_parser\n        if: body matches \"^\\\\{\"\n        parse_from: body\n        parse_to: attributes\n        on_error: send\n```\n\nLet’s walk through each step.\n\n### 1\\. `container`: unwrap the Docker envelope\n\nThe `container` operator parses Docker’s `json-file` format (and CRI format too). It extracts the `log` field as the body, `stream` as an attribute, and `time` as the timestamp. After this step, the body is your app’s raw log line, or part of it, but still a string, not yet parsed.\n\n`add_metadata_from_filepath: false` turns off the Kubernetes metadata the operator reads from a pod’s log file path. Docker’s log paths don’t contain that metadata, and with the option on, the operator logs an error for every record unless the receiver also adds the file path (`include_file_path: true`).\n\n### 2\\. `filter`: drop non-JSON lines\n\nNot everything your app writes to stdout is JSON. Puma access logs, Ruby warnings, and middleware timing lines (like `Rack handled in 18.27ms. 0 GCs.`) are plain text. We don’t want these in Loki, and some of our pipelines pick up lines by container name alone, so we drop them here.\n\nWe need to be careful because of how Docker splits long lines. The first chunk of a split line starts with `{` but the middle chunks won’t, and only the final chunk ends with a newline. We can’t just drop everything that doesn’t look like JSON, or we’d lose pieces of split log lines.\n\nThe filter drops lines that start with a non-`{` character **and** don’t end with `}\\n`:\n\n```plaintext\nbody matches \"^[^{]\" and body matches \"[^}]\\\\n$\"\n```\n\nA complete JSON line starts with `{`, so the first condition is false and it passes through. A middle chunk of a split JSON line has no newline at the end, so the second condition is false and it also passes through. Only standalone plain-text lines match both conditions and get dropped.\n\n### 3\\. `recombine`: reassemble split JSON\n\nDocker’s `json-file` driver splits log lines at 16KB boundaries. A single JSON log from your app might become two or three Docker log entries. The `container` operator handles this for CRI format but not for Docker’s `json-file` format, so we do it ourselves.\n\nThe recombine operator concatenates consecutive entries until it sees one whose body ends with a newline, which is how Docker marks the end of a line:\n\n```yaml\n- type: recombine\n  combine_field: body\n  source_identifier: attributes[\"log.file.name\"]\n  combine_with: \"\"\n  is_last_entry: body matches \"\\n$\"\n  max_unmatched_batch_size: 0\n```\n\n`source_identifier` ensures we only recombine entries from the same log file, so logs from different containers don’t get mixed up. That’s important when Kamal runs multiple containers on the same host during deploys. `combine_with: \"\"` concatenates without adding any separator, since Docker’s splits are at arbitrary byte boundaries.\n\n`max_unmatched_batch_size: 0` turns off a default limit of 100 entries. Without it, recombine sends on what it has after 100 partial records, so any line over 1.6MB gets cut in two.\n\n### 4\\. `json_parser`: parse the app JSON\n\nNow the body contains the complete, reassembled JSON string from your app. The `json_parser` operator parses it and writes the resulting fields into the log record’s attributes:\n\n```yaml\n- type: json_parser\n  if: body matches \"^\\\\{\"\n  parse_from: body\n  parse_to: attributes\n  on_error: send\n```\n\nAfter this, `attributes[\"rails\"][\"controller\"]` is `\"PostsController\"`, `attributes[\"http\"][\"response\"][\"status_code\"]` is `200`, and so on. The log record is now fully structured and ready for routing, transformation, and export.\n\nThe `if` skips anything that doesn’t start with `{`, so the parser doesn’t log an error for every piece of plain text that reaches it. `on_error: send` passes a record that fails to parse on with its raw body, and the collector logs the error. We count those errors in the collector’s own logs, and that’s how we found the two problems below.\n\n* * *\n\n## Two things we got wrong\n\nOur first version of this chain ended a line on `}\\n` rather than `\\n`, and left `max_unmatched_batch_size` at its default. Both lost log lines.\n\nWhen a line is an exact multiple of 16KB before its newline, Docker’s split falls between the closing `}` and the `\\n`. The last record is then just `\"\\n\"`. Neither record matched `}\\n$`, so recombine kept going and joined the next log line on as well. That gave us two JSON objects in one body, `invalid character '{' after top-level value` from the parser, and two lost lines. It only happens to about 1 in 16,384 long lines, but on Basecamp that was around 320 times an hour.\n\nThe 100-record limit cut lines over 1.6MB in two. The first part failed to parse with `unexpected end of JSON input` and the rest was dropped. That was about 110 lines every 6 hours on Basecamp.\n\nWe now test the receiver config by running the collector against sample Docker log files, including a line split just before its newline and a line in more than 100 records, and checking which records come out.\n\n* * *\n\n## What comes next\n\nWith structured attributes on each record, you can use the [routing connector](https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/connector/routingconnector) to send different log formats to different pipelines, and the [transform processor](https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/processor/transformprocessor) to normalize fields, set severity levels, and enrich logs before exporting them.\n\nThe operator chain runs *inside* the receiver, before logs enter the pipeline. Getting the unwrap → filter → recombine → parse sequence right here means your processors only ever see clean, fully-parsed log records.\n\n* * *\n\n- [Other posts in the “On-prem observability” series](https://dev.37signals.com/series/on-prem-observability/)\n",
  "dateCreated": "2026-10-19T19:14:52.283302801+00:00",
  "date_created": "2026-10-19T17:00:00+00:00",
  "date_modified": "2026-10-19T17:00:00+00:00",
  "date_published": "2026-10-19T17:00:00+00:00",
  "description": "How we use the filelog receiver’s operator chain to parse, recombine, and route JSON-structured log lines from Docker containers.",
  "external_url": "https://dev.37signals.com/parsing-json-docker-logs-with-opentelemetry-collector/",
  "git": {
    "commit": "ebb0941c5c06a2ab2377342d607535262a3e969e",
    "path": "items/dev-37signals-com/2026/10/2026-10-19-parsing-json-structured-docker-logs-with-the-opentelemetry.md",
    "permalink": "https://github.com/aymericbeaumet/aggr-instance/blob/ebb0941c5c06a2ab2377342d607535262a3e969e/items/dev-37signals-com/2026/10/2026-10-19-parsing-json-structured-docker-logs-with-the-opentelemetry.md"
  },
  "id": "urn:aggr:item:3ab5a446b92e4845a1152b3514a95ef80c7f20b6",
  "isBasedOn": {
    "@id": "https://dev.37signals.com/parsing-json-docker-logs-with-opentelemetry-collector/"
  },
  "mainEntity": {
    "@id": "https://dev.37signals.com/parsing-json-docker-logs-with-opentelemetry-collector/",
    "@type": "CreativeWork",
    "archivedAt": {
      "@id": "https://aggr.aymericbeaumet.com/items/dev-37signals-com/2026-10-19-parsing-json-structured-docker-logs-with-the-opentelemetry/#webpage"
    },
    "author": [
      {
        "@type": "Person",
        "name": "Lewis Buckley"
      }
    ],
    "dateModified": "2026-10-19T17:00:00+00:00",
    "datePublished": "2026-10-19T17:00:00+00:00",
    "headline": "Parsing JSON-structured Docker logs with the OpenTelemetry Collector",
    "image": {
      "@type": "ImageObject",
      "caption": "37signals Dev",
      "height": 90,
      "url": "https://dev.37signals.com/assets/images/opengraph/parsing-json-docker-logs-with-opentelemetry-collector.png",
      "width": 160
    },
    "timeRequired": "PT6M",
    "url": "https://dev.37signals.com/parsing-json-docker-logs-with-opentelemetry-collector/",
    "wordCount": 1270
  },
  "name": "Archived snapshot: Parsing JSON-structured Docker logs with the OpenTelemetry Collector",
  "preview": {
    "alt": "37signals Dev",
    "color": null,
    "height": 90,
    "placeholder": {
      "data_url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
      "hash": ""
    },
    "url": "https://dev.37signals.com/assets/images/opengraph/parsing-json-docker-logs-with-opentelemetry-collector.png",
    "width": 160
  },
  "reading_minutes": 6,
  "replicated_at": null,
  "source": "dev-37signals-com",
  "source_name": "37signals Dev",
  "summary": "How we use the filelog receiver’s operator chain to parse, recombine, and route JSON-structured log lines from Docker containers.",
  "tags": [],
  "temporalCoverage": "2026-10-19T19:14:52.283302801+00:00",
  "title": "Parsing JSON-structured Docker logs with the OpenTelemetry Collector",
  "url": "https://aggr.aymericbeaumet.com/items/dev-37signals-com/2026-10-19-parsing-json-structured-docker-logs-with-the-opentelemetry/",
  "word_count": 1270
}