<?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>Forem: Dean Hamstead</title>
    <description>The latest articles on Forem by Dean Hamstead (@perldean).</description>
    <link>https://forem.com/perldean</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F644263%2F9361c8b8-0dd4-4c45-ab9c-6b62cceb0d0d.png</url>
      <title>Forem: Dean Hamstead</title>
      <link>https://forem.com/perldean</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/perldean"/>
    <language>en</language>
    <item>
      <title>Syslog to PostgreSQL via Rsyslog: A Production-Ready Setup</title>
      <dc:creator>Dean Hamstead</dc:creator>
      <pubDate>Sat, 04 Apr 2026 13:08:49 +0000</pubDate>
      <link>https://forem.com/perldean/syslog-to-postgresql-via-rsyslog-a-production-ready-setup-1gbe</link>
      <guid>https://forem.com/perldean/syslog-to-postgresql-via-rsyslog-a-production-ready-setup-1gbe</guid>
      <description>&lt;p&gt;Syslog is the backbone of infrastructure logging, but storing logs as flat files makes querying, retention management, and analysis painful. The industry's knee-jerk reaction is to stand up Elasticsearch. Having run Elasticsearch extensively in production—for both high-write log ingestion/charting and high-read complex catalog searches—I am frankly exhausted by it. I am tired of Java. I'm tired of babysitting the JVM, tweaking heap sizes, fighting garbage collection pauses, and dedicating massive amounts of RAM just to keep the cluster from going red. If you already run PostgreSQL, you might not need to inflict that on yourself. While Elasticsearch demands a dedicated cluster and specialized operational knowledge, this PostgreSQL approach elegantly bolts log storage onto your existing database infrastructure.&lt;/p&gt;

&lt;p&gt;In this post, I'll walk through a production-ready setup that pipes syslog directly into PostgreSQL via rsyslog, using features like range partitioning and &lt;code&gt;pg_cron&lt;/code&gt; to achieve many of the same benefits as Elasticsearch's time-based indices and Index Lifecycle Management (ILM) — without the Java Virtual Machine (JVM) overhead or the separate cluster.&lt;/p&gt;

&lt;p&gt;The full SQL schema, rsyslog configs, and everything discussed here is available for you to use as-is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture Overview
&lt;/h2&gt;

&lt;p&gt;The setup has two parts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Client side&lt;/strong&gt; — forwards logs via TCP using rsyslog's &lt;code&gt;omfwd&lt;/code&gt; module&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server side&lt;/strong&gt; — receives logs on UDP, TCP, and (optionally) Reliable Event Logging Protocol (RELP) ports and writes them to PostgreSQL using &lt;code&gt;ompgsql&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌──────────────┐
│ Linux Server │ ─┐
└──────────────┘  │   TCP/UDP 514   ┌──────────────────┐
                  ├────────────────▶│   Intermediate   │
┌──────────────┐  │                 │  Rsyslog Server  │
│     NAS      │ ─┘                 └────────┬─────────┘
└──────────────┘                             │
                                             │ RELP 2514
┌──────────────┐                             │
│   OPNsense   │ ─┐                      ┌───▼──────────────┐
└──────────────┘  │   TCP/UDP 514        │   Main Rsyslog   │
                  ├─────────────────────▶│      Server      │
┌──────────────┐  │                      └────────┬─────────┘
│Managed Switch│ ─┘                               │
└──────────────┘                           ompgsql│
                                                  ▼
                                         ┌──────────────────┐
                                         │   PostgreSQL     │
                                         │   (syslog DB)    │
                                         └──────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;(Note: The Intermediate Rsyslog Server shown above is entirely optional. It is included here to illustrate how you can meet strict security requirements—such as aggregating logs from a DMZ or isolated network segment before securely forwarding them via RELP to your main logging infrastructure.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Client Configuration
&lt;/h2&gt;

&lt;p&gt;On each log-producing host, the forwarding config is minimal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;action&lt;/span&gt;(
    &lt;span class="n"&gt;type&lt;/span&gt;=&lt;span class="s2"&gt;"omfwd"&lt;/span&gt;
    &lt;span class="n"&gt;target&lt;/span&gt;=&lt;span class="s2"&gt;"192.0.2.1"&lt;/span&gt;
    &lt;span class="n"&gt;port&lt;/span&gt;=&lt;span class="s2"&gt;"514"&lt;/span&gt;
    &lt;span class="n"&gt;protocol&lt;/span&gt;=&lt;span class="s2"&gt;"tcp"&lt;/span&gt;
    &lt;span class="n"&gt;template&lt;/span&gt;=&lt;span class="s2"&gt;"RSYSLOG_SyslogProtocol23Format"&lt;/span&gt;
)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;TCP is used instead of UDP for reliable delivery — no dropped log lines on a busy network. The &lt;code&gt;RSYSLOG_SyslogProtocol23Format&lt;/code&gt; template ensures RFC 5424 compliance, giving us structured fields like &lt;code&gt;msgid&lt;/code&gt; and &lt;code&gt;programname&lt;/code&gt; on the receiving end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Server Configuration
&lt;/h2&gt;

&lt;p&gt;The server receives logs on three protocols and writes them to PostgreSQL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;module&lt;/span&gt;(&lt;span class="n"&gt;load&lt;/span&gt;=&lt;span class="s2"&gt;"imudp"&lt;/span&gt;)
&lt;span class="n"&gt;module&lt;/span&gt;(&lt;span class="n"&gt;load&lt;/span&gt;=&lt;span class="s2"&gt;"imtcp"&lt;/span&gt;)
&lt;span class="n"&gt;module&lt;/span&gt;(&lt;span class="n"&gt;load&lt;/span&gt;=&lt;span class="s2"&gt;"imrelp"&lt;/span&gt;)

&lt;span class="n"&gt;input&lt;/span&gt;(
    &lt;span class="n"&gt;type&lt;/span&gt;=&lt;span class="s2"&gt;"imudp"&lt;/span&gt;
    &lt;span class="n"&gt;port&lt;/span&gt;=&lt;span class="s2"&gt;"514"&lt;/span&gt;
    &lt;span class="n"&gt;rateLimit&lt;/span&gt;.&lt;span class="n"&gt;interval&lt;/span&gt;=&lt;span class="s2"&gt;"60"&lt;/span&gt;
    &lt;span class="n"&gt;rateLimit&lt;/span&gt;.&lt;span class="n"&gt;burst&lt;/span&gt;=&lt;span class="s2"&gt;"2000"&lt;/span&gt;
)

&lt;span class="n"&gt;input&lt;/span&gt;(
    &lt;span class="n"&gt;type&lt;/span&gt;=&lt;span class="s2"&gt;"imtcp"&lt;/span&gt;
    &lt;span class="n"&gt;port&lt;/span&gt;=&lt;span class="s2"&gt;"514"&lt;/span&gt;
)

&lt;span class="n"&gt;input&lt;/span&gt;(
    &lt;span class="n"&gt;type&lt;/span&gt;=&lt;span class="s2"&gt;"imrelp"&lt;/span&gt;
    &lt;span class="n"&gt;port&lt;/span&gt;=&lt;span class="s2"&gt;"2514"&lt;/span&gt;
    &lt;span class="n"&gt;maxDataSize&lt;/span&gt;=&lt;span class="s2"&gt;"10k"&lt;/span&gt;
)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three protocols for three use cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;UDP/514&lt;/strong&gt; — universal compatibility, with rate limiting (&lt;code&gt;burst="2000"&lt;/code&gt; per 60 seconds) to prevent a noisy host from overwhelming the server&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TCP/514&lt;/strong&gt; — reliable delivery for clients that support it&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RELP/2514 (Optional)&lt;/strong&gt; — transaction-based syslog protocol with guaranteed delivery and replay on failure. It's a nice-to-have for rsyslog-to-rsyslog forwarding on unstable links, but standard TCP is sufficient for most deployments.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The PostgreSQL Output Template
&lt;/h3&gt;

&lt;p&gt;The heart of the pipeline is the &lt;a href="https://docs.rsyslog.com/doc//configuration/modules/ompgsql.html" rel="noopener noreferrer"&gt;&lt;code&gt;ompgsql&lt;/code&gt; module&lt;/a&gt; with a list-type template:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;template&lt;/span&gt;(
    &lt;span class="n"&gt;name&lt;/span&gt;=&lt;span class="s2"&gt;"LogToPgSQL"&lt;/span&gt; &lt;span class="n"&gt;type&lt;/span&gt;=&lt;span class="s2"&gt;"list"&lt;/span&gt; &lt;span class="n"&gt;option&lt;/span&gt;.&lt;span class="n"&gt;stdsql&lt;/span&gt;=&lt;span class="s2"&gt;"on"&lt;/span&gt; ) {
    &lt;span class="n"&gt;constant&lt;/span&gt;(&lt;span class="n"&gt;value&lt;/span&gt;=&lt;span class="s2"&gt;"INSERT INTO system_events (...) VALUES ('"&lt;/span&gt;)
    &lt;span class="n"&gt;property&lt;/span&gt;(&lt;span class="n"&gt;name&lt;/span&gt;=&lt;span class="s2"&gt;"timereported"&lt;/span&gt; &lt;span class="n"&gt;dateFormat&lt;/span&gt;=&lt;span class="s2"&gt;"pgsql"&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;.&lt;span class="n"&gt;inUTC&lt;/span&gt;=&lt;span class="s2"&gt;"on"&lt;/span&gt;)
    &lt;span class="n"&gt;constant&lt;/span&gt;(&lt;span class="n"&gt;value&lt;/span&gt;=&lt;span class="s2"&gt;"', '"&lt;/span&gt;)
    &lt;span class="n"&gt;property&lt;/span&gt;(&lt;span class="n"&gt;name&lt;/span&gt;=&lt;span class="s2"&gt;"msgid"&lt;/span&gt;)
    &lt;span class="n"&gt;constant&lt;/span&gt;(&lt;span class="n"&gt;value&lt;/span&gt;=&lt;span class="s2"&gt;"', 'syslog', '"&lt;/span&gt;)
    &lt;span class="n"&gt;property&lt;/span&gt;(&lt;span class="n"&gt;name&lt;/span&gt;=&lt;span class="s2"&gt;"programname"&lt;/span&gt;)
    -- ... &lt;span class="n"&gt;more&lt;/span&gt; &lt;span class="n"&gt;fields&lt;/span&gt; ...
    &lt;span class="n"&gt;property&lt;/span&gt;(&lt;span class="n"&gt;name&lt;/span&gt;=&lt;span class="s2"&gt;"msg"&lt;/span&gt; &lt;span class="n"&gt;escape&lt;/span&gt;=&lt;span class="s2"&gt;"sql"&lt;/span&gt;)
    -- ... &lt;span class="n"&gt;more&lt;/span&gt; &lt;span class="n"&gt;fields&lt;/span&gt; ...
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details worth highlighting:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;dateFormat="pgsql"&lt;/code&gt; and &lt;code&gt;date.inUTC="on"&lt;/code&gt;&lt;/strong&gt; — Syslog timestamps are notoriously inconsistent across different devices. This combination forces rsyslog to standardize everything to UTC at the ingestion point and format it exactly as PostgreSQL's &lt;code&gt;TIMESTAMP WITH TIME ZONE&lt;/code&gt; expects (&lt;code&gt;YYYY-MM-DD HH:MM:SS.mmm+TZ&lt;/code&gt;). No string parsing on the database side, and no timezone nightmares when querying later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;escape="sql"&lt;/code&gt;&lt;/strong&gt; — built-in SQL escaping on &lt;code&gt;msg&lt;/code&gt; and &lt;code&gt;rawmsg&lt;/code&gt; prevents injection from malicious or malformed log content.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;em&gt;(For a complete list of variables you can extract from syslog messages, check out the &lt;a href="https://docs.rsyslog.com/doc//configuration/properties.html" rel="noopener noreferrer"&gt;official Rsyslog properties documentation&lt;/a&gt;.)&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Queue Configuration for Resilience
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;action&lt;/span&gt;(
    &lt;span class="n"&gt;type&lt;/span&gt;=&lt;span class="s2"&gt;"ompgsql"&lt;/span&gt; &lt;span class="n"&gt;server&lt;/span&gt;=&lt;span class="s2"&gt;"localhost"&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt;=&lt;span class="s2"&gt;"rsyslog"&lt;/span&gt; &lt;span class="n"&gt;pass&lt;/span&gt;=&lt;span class="s2"&gt;"..."&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;=&lt;span class="s2"&gt;"syslog"&lt;/span&gt; &lt;span class="n"&gt;template&lt;/span&gt;=&lt;span class="s2"&gt;"LogToPgSQL"&lt;/span&gt;

    &lt;span class="n"&gt;queue&lt;/span&gt;.&lt;span class="n"&gt;type&lt;/span&gt;=&lt;span class="s2"&gt;"linkedList"&lt;/span&gt;
    &lt;span class="n"&gt;queue&lt;/span&gt;.&lt;span class="n"&gt;size&lt;/span&gt;=&lt;span class="s2"&gt;"20000"&lt;/span&gt;
    &lt;span class="n"&gt;queue&lt;/span&gt;.&lt;span class="n"&gt;workerThreads&lt;/span&gt;=&lt;span class="s2"&gt;"2"&lt;/span&gt;
)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The linked-list queue with 20,000 message capacity and 2 worker threads provides a basic buffer during database hiccups. If PostgreSQL briefly stalls, rsyslog holds messages in memory rather than dropping them. Two worker threads keep the pipeline flowing even when one thread is blocked on a slow write.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A Note for Production Deployments:&lt;/strong&gt;&lt;br&gt;
If you are implementing this in a production environment, Rsyslog's queuing subsystem is something you should absolutely dig deeper into. It is a killer feature for log reliability. Beyond simple in-memory linked lists, Rsyslog supports &lt;strong&gt;Disk-Assisted Queues&lt;/strong&gt;. &lt;/p&gt;

&lt;p&gt;If PostgreSQL goes down for an extended maintenance window, or if a broadcast storm triggers a massive log spike that exceeds your database write speed, a disk-assisted queue will seamlessly spill the buffered logs onto the local disk. Once the database recovers, Rsyslog drains the disk queue into PostgreSQL. This guarantees zero log loss during database outages or extreme traffic spikes—a level of resilience that usually requires deploying a dedicated message broker like Kafka in other logging stacks.&lt;/p&gt;
&lt;h2&gt;
  
  
  The PostgreSQL Schema
&lt;/h2&gt;

&lt;p&gt;Now for the centerpiece of this setup: the database schema. This is where PostgreSQL starts to look a lot like a log aggregation platform.&lt;/p&gt;
&lt;h3&gt;
  
  
  1. Range Partitioning — PostgreSQL's Answer to Time-Based Indices
&lt;/h3&gt;

&lt;p&gt;Elasticsearch handles log retention by creating a new index per time period — &lt;code&gt;logs-2025.01.01&lt;/code&gt;, &lt;code&gt;logs-2025.01.02&lt;/code&gt;, and so on. When it's time to delete old data, it drops the entire index. Instant, no compaction, no garbage collection.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.postgresql.org/docs/current/ddl-partitioning.html" rel="noopener noreferrer"&gt;PostgreSQL range partitioning&lt;/a&gt; is the same concept, expressed in SQL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;system_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;ID&lt;/span&gt; &lt;span class="nb"&gt;SERIAL&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ReceivedAt&lt;/span&gt; &lt;span class="nb"&gt;TIMESTAMP&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="nb"&gt;TIME&lt;/span&gt; &lt;span class="k"&gt;ZONE&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;DeviceReportedTime&lt;/span&gt; &lt;span class="nb"&gt;TIMESTAMP&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="nb"&gt;TIME&lt;/span&gt; &lt;span class="k"&gt;ZONE&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;EventID&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;EventLogType&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;EventSource&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;Facility&lt;/span&gt; &lt;span class="nb"&gt;SMALLINT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;FromHost&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;FromPort&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;FromIpAddress&lt;/span&gt; &lt;span class="n"&gt;INET&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;HostName&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Message&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Priority&lt;/span&gt; &lt;span class="nb"&gt;SMALLINT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;RawMessage&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;RFC5424AppName&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;RFC5424MsgID&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;RFC5424ProcID&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;RFC5424ProtocolVersion&lt;/span&gt; &lt;span class="nb"&gt;SMALLINT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;RFC5424StructuredData&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;SysLogTag&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;message_tsv&lt;/span&gt; &lt;span class="n"&gt;tsvector&lt;/span&gt;
        &lt;span class="k"&gt;GENERATED&lt;/span&gt; &lt;span class="n"&gt;ALWAYS&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;to_tsvector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="n"&gt;STORED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ReceivedAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;RANGE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ReceivedAt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;ReceivedAt&lt;/code&gt; column is the partition key — every row is routed to the correct monthly partition automatically. The primary key includes &lt;code&gt;ReceivedAt&lt;/code&gt; because PostgreSQL requires the partition key to be part of any unique constraint on a partitioned table.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;message_tsv&lt;/code&gt; column is a &lt;strong&gt;generated column&lt;/strong&gt; that automatically maintains a full-text search vector from the &lt;code&gt;Message&lt;/code&gt; column. It's always in sync — no application logic, no triggers, no stale data.&lt;/p&gt;

&lt;p&gt;Monthly partitions are created like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;system_events_2026_03&lt;/span&gt; &lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;OF&lt;/span&gt; &lt;span class="n"&gt;system_events&lt;/span&gt;
    &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2026-03-01'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2026-04-01'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;system_events_2026_04&lt;/span&gt; &lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;OF&lt;/span&gt; &lt;span class="n"&gt;system_events&lt;/span&gt;
    &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2026-04-01'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2026-05-01'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Partition Pruning in Action
&lt;/h4&gt;

&lt;p&gt;Here's where partitioning pays off. When you query a date range, PostgreSQL's planner knows exactly which partitions to scan — and skips the rest entirely. This is called &lt;strong&gt;partition pruning&lt;/strong&gt;, and it's the same mechanism Elasticsearch uses to skip irrelevant shards.&lt;/p&gt;

&lt;p&gt;Watch what happens when we query a date range that has no matching partition:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;EXPLAIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ANALYZE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BUFFERS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;system_events&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;ReceivedAt&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="s1"&gt;'2025-03-01'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;ReceivedAt&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="s1"&gt;'2025-04-01'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;                                     &lt;span class="n"&gt;QUERY&lt;/span&gt; &lt;span class="n"&gt;PLAN&lt;/span&gt;
&lt;span class="c1"&gt;------------------------------------------------------------------------------------&lt;/span&gt;
 &lt;span class="k"&gt;Result&lt;/span&gt;  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cost&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;00&lt;/span&gt;&lt;span class="p"&gt;..&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;00&lt;/span&gt; &lt;span class="k"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;001&lt;/span&gt;&lt;span class="p"&gt;..&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;001&lt;/span&gt; &lt;span class="k"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="n"&gt;loops&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
   &lt;span class="n"&gt;One&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nb"&gt;Time&lt;/span&gt; &lt;span class="n"&gt;Filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;
 &lt;span class="n"&gt;Planning&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
   &lt;span class="n"&gt;Buffers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;shared&lt;/span&gt; &lt;span class="n"&gt;hit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;
 &lt;span class="n"&gt;Planning&lt;/span&gt; &lt;span class="nb"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;053&lt;/span&gt; &lt;span class="n"&gt;ms&lt;/span&gt;
 &lt;span class="n"&gt;Execution&lt;/span&gt; &lt;span class="nb"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;012&lt;/span&gt; &lt;span class="n"&gt;ms&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;0.012 milliseconds.&lt;/strong&gt; The planner determined at plan time that no partition covers March 2025, so it didn't scan anything at all. &lt;code&gt;One-Time Filter: false&lt;/code&gt; means the entire query was short-circuited. This is partition pruning at its most effective.&lt;/p&gt;

&lt;p&gt;On a real query that hits a partition, PostgreSQL scans only the matching partition — not January, not February, not April. The other partitions are invisible to the query.&lt;/p&gt;

&lt;h4&gt;
  
  
  Dropping Partitions vs. DELETE
&lt;/h4&gt;

&lt;p&gt;This is the killer feature of partitioning for log retention. When it's time to delete old data, you don't run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;system_events&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;ReceivedAt&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="s1"&gt;'2026-01-01'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;DELETE&lt;/code&gt; on millions of rows means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Scanning millions of rows to find the ones to delete&lt;/li&gt;
&lt;li&gt;Generating massive WAL (write-ahead log)&lt;/li&gt;
&lt;li&gt;Leaving dead tuples behind that require &lt;code&gt;VACUUM&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;VACUUM&lt;/code&gt; competing with your ingestion workload&lt;/li&gt;
&lt;li&gt;Table bloat until autovacuum catches up&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Instead, you drop the partition:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;system_events_2025_12&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is instant. No scanning, no WAL, no VACUUM, no bloat. The entire partition file is removed from disk in a single filesystem operation. It's the same advantage Elasticsearch gets from dropping an index.&lt;/p&gt;

&lt;h4&gt;
  
  
  Real-World Partition Stats
&lt;/h4&gt;

&lt;p&gt;In this deployment, the partitioned table holds &lt;strong&gt;2.12 million rows&lt;/strong&gt; across seven monthly partitions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Partition&lt;/th&gt;
&lt;th&gt;Rows&lt;/th&gt;
&lt;th&gt;Table Size&lt;/th&gt;
&lt;th&gt;Index Size&lt;/th&gt;
&lt;th&gt;Total&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2026_01&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;248 MB&lt;/td&gt;
&lt;td&gt;70 MB&lt;/td&gt;
&lt;td&gt;331 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026_02&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;269 MB&lt;/td&gt;
&lt;td&gt;51 MB&lt;/td&gt;
&lt;td&gt;334 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026_03&lt;/td&gt;
&lt;td&gt;43,310&lt;/td&gt;
&lt;td&gt;493 MB&lt;/td&gt;
&lt;td&gt;95 MB&lt;/td&gt;
&lt;td&gt;608 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026_04&lt;/td&gt;
&lt;td&gt;134,439&lt;/td&gt;
&lt;td&gt;70 MB&lt;/td&gt;
&lt;td&gt;14 MB&lt;/td&gt;
&lt;td&gt;86 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026_05+&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0 bytes&lt;/td&gt;
&lt;td&gt;160 kB&lt;/td&gt;
&lt;td&gt;168 kB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Note that &lt;code&gt;pg_total_relation_size('system_events')&lt;/code&gt; on the parent table returns 0 bytes — this is expected. The parent table in a partitioned setup is just a logical container. All the data lives in the child partitions.&lt;/p&gt;

&lt;p&gt;Index overhead sits at &lt;strong&gt;19-28%&lt;/strong&gt; of table size, which is very reasonable. The primary key index is the largest single index component — a consequence of including &lt;code&gt;ReceivedAt&lt;/code&gt; in the composite key for partitioning compatibility.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Index Strategy — Designed for How Logs Are Actually Queried
&lt;/h3&gt;

&lt;p&gt;The schema includes a deliberately chosen set of indexes, each serving a specific query pattern:&lt;/p&gt;

&lt;h4&gt;
  
  
  BRIN Indexes on Time Columns
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_system_events_receivedat_brin&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;system_events&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;brin&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ReceivedAt&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_system_events_devicereportedtime_brin&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;system_events&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;brin&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DeviceReportedTime&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://www.postgresql.org/docs/current/brin.html" rel="noopener noreferrer"&gt;BRIN (Block Range Index)&lt;/a&gt; stores the minimum and maximum values for each block range in the table. For append-only, time-ordered data like syslog, this is near-perfect. A BRIN index on &lt;code&gt;ReceivedAt&lt;/code&gt; is typically &lt;strong&gt;~200 KB&lt;/strong&gt; — compared to ~23 MB for an equivalent B-tree index. That's a 99% reduction in index size with nearly identical performance for range scans.&lt;/p&gt;

&lt;p&gt;BRIN works best for naturally ordered data like timestamps. If your log ingestion has significant out-of-order delivery (hours/days delayed), B-tree might be preferable despite the size difference.&lt;/p&gt;

&lt;h4&gt;
  
  
  B-tree Indexes on Common Filters
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_system_events_fromhost&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;system_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;FromHost&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_system_events_eventsource&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;system_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;EventSource&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_system_events_facility&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;system_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Facility&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_system_events_priority&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;system_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Priority&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These cover the most common queries: "show me all logs from this host," "show me all sshd logs," "show me all auth facility messages." Simple, effective, and small enough to stay in memory.&lt;/p&gt;

&lt;h4&gt;
  
  
  Partial Index for Errors
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_system_events_errors&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;system_events&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;FromHost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;EventSource&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ReceivedAt&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;Priority&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a &lt;strong&gt;partial composite index&lt;/strong&gt; — it only indexes rows where &lt;code&gt;Priority &amp;lt;= 3&lt;/code&gt; (emerg, alert, crit, err), which is about 9% of all log messages. The three-column structure covers the equality filters (&lt;code&gt;FromHost&lt;/code&gt;, &lt;code&gt;EventSource&lt;/code&gt;) and the sort (&lt;code&gt;ReceivedAt DESC&lt;/code&gt;) in a single index. Error dashboards hit this index and never touch the other 91% of rows.&lt;/p&gt;

&lt;h4&gt;
  
  
  GIN Index for Full-Text Search
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_system_events_message_fts&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;system_events&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;gin&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message_tsv&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This Generalized Inverted Index (GIN) is what closes the biggest gap vs. Elasticsearch. It enables fast keyword search across all log messages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;ReceivedAt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;FromHost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;EventSource&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;ts_headline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;system_events&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to_tsquery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'authentication &amp;amp; failure'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;message_tsv&lt;/span&gt; &lt;span class="o"&gt;@@&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;ReceivedAt&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Tradeoff&lt;/strong&gt;: the &lt;code&gt;message_tsv&lt;/code&gt; column adds ~20-30% to storage per row. If you don't need keyword search, omit the column and its index.&lt;/p&gt;

&lt;p&gt;In testing, searching for 'authentication failure' across 2M rows took ~15ms with the GIN index vs. several seconds with a sequential scan.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. pg_cron — PostgreSQL's Answer to Index Lifecycle Management
&lt;/h3&gt;

&lt;p&gt;Elasticsearch has Index Lifecycle Management (ILM) — a built-in system that automatically creates new indices, rolls over old ones, and deletes expired data. PostgreSQL doesn't have ILM baked in, but it has &lt;a href="https://github.com/citusdata/pg_cron" rel="noopener noreferrer"&gt;&lt;code&gt;pg_cron&lt;/code&gt;&lt;/a&gt;, and with a few stored procedures, you get the same result.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;pg_cron&lt;/code&gt; runs scheduled SQL jobs directly inside PostgreSQL — no external cron daemon, no shell scripts, no connection management. It's cron, but it speaks SQL.&lt;/p&gt;

&lt;h4&gt;
  
  
  The Stored Procedures
&lt;/h4&gt;

&lt;p&gt;Three procedures handle the full partition lifecycle:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;create_monthly_partition&lt;/code&gt;&lt;/strong&gt; — creates a single partition for a given year and month, idempotent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;PROCEDURE&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;create_monthly_partition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;p_table_name&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;year&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;month&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;DECLARE&lt;/span&gt;
    &lt;span class="n"&gt;partition_name&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;start_date&lt;/span&gt; &lt;span class="nb"&gt;DATE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;end_date&lt;/span&gt; &lt;span class="nb"&gt;DATE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
    &lt;span class="n"&gt;start_date&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;make_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;end_date&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;start_date&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'1 month'&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;DATE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;partition_name&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'%s_%s_%s'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p_table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lpad&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;month&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'0'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_tables&lt;/span&gt;
        &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;tablename&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;partition_name&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;schemaname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt;
        &lt;span class="k"&gt;EXECUTE&lt;/span&gt; &lt;span class="n"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s1"&gt;'CREATE TABLE public.%I PARTITION OF public.%I FOR VALUES FROM (%L) TO (%L)'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;partition_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p_table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;start_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;end_date&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;RAISE&lt;/span&gt; &lt;span class="n"&gt;NOTICE&lt;/span&gt; &lt;span class="s1"&gt;'Created partition public.%'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;partition_name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;ELSE&lt;/span&gt;
        &lt;span class="n"&gt;RAISE&lt;/span&gt; &lt;span class="n"&gt;NOTICE&lt;/span&gt; &lt;span class="s1"&gt;'Partition public.% already exists, skipping'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;partition_name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;create_future_partitions&lt;/code&gt;&lt;/strong&gt; — loops N months ahead and creates them all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;PROCEDURE&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;create_future_partitions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;p_table_name&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;months_ahead&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;DECLARE&lt;/span&gt;
    &lt;span class="k"&gt;current_date&lt;/span&gt; &lt;span class="nb"&gt;DATE&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;CURRENT_DATE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;target_date&lt;/span&gt; &lt;span class="nb"&gt;DATE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
    &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;..&lt;/span&gt;&lt;span class="n"&gt;months_ahead&lt;/span&gt; &lt;span class="n"&gt;LOOP&lt;/span&gt;
        &lt;span class="n"&gt;target_date&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;current_date&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'1 month'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;CALL&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;create_monthly_partition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;p_table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="k"&gt;EXTRACT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;YEAR&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;target_date&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="k"&gt;EXTRACT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;MONTH&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;target_date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="n"&gt;LOOP&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;drop_old_partitions&lt;/code&gt;&lt;/strong&gt; — drops partitions older than a retention interval:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;PROCEDURE&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;drop_old_partitions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;p_table_name&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;retention_interval&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;DECLARE&lt;/span&gt;
    &lt;span class="n"&gt;partition_name&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
    &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="n"&gt;partition_name&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;tables&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;table_name&lt;/span&gt;
        &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tables&lt;/span&gt;
        &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;tables&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;table_name&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="n"&gt;p_table_name&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;'_%'&lt;/span&gt;
        &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;tables&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;table_name&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;p_table_name&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;'_'&lt;/span&gt;
            &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;to_char&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;CURRENT_DATE&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;retention_interval&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'YYYY_MM'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;tables&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;table_schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;LOOP&lt;/span&gt;
        &lt;span class="k"&gt;EXECUTE&lt;/span&gt; &lt;span class="s1"&gt;'DROP TABLE IF EXISTS public.'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;quote_ident&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;partition_name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;RAISE&lt;/span&gt; &lt;span class="n"&gt;NOTICE&lt;/span&gt; &lt;span class="s1"&gt;'Dropped partition public.%'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;partition_name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="n"&gt;LOOP&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  The Cron Jobs
&lt;/h4&gt;

&lt;p&gt;Two scheduled jobs keep the system self-managing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;cron&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;schedule_in_database&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'drop_old_system_events_partitions'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'0 14 1 * *'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="k"&gt;CALL&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;drop_old_partitions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'system_events'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'3 months'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'syslog'&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;cron&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;schedule_in_database&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'create_future_system_events_partitions'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'5 14 1 * *'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="k"&gt;CALL&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;create_future_partitions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'system_events'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'syslog'&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the first of every month at 14:00:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Old partitions are dropped&lt;/strong&gt; — anything older than 3 months vanishes instantly&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Future partitions are created&lt;/strong&gt; — the next 3 months are pre-provisioned&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Five minutes between jobs ensures the drop completes before new partitions are created. No external scripts, no SSH keys, no cron entry management. The entire lifecycle runs inside PostgreSQL, observable through standard system tables:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;jobid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;jobname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;command&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;cron&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;jobid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;start_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;end_time&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;cron&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;job_run_details&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;start_time&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In this deployment, the job history tells a nice story: the cron jobs were scheduled before the &lt;code&gt;syslog&lt;/code&gt; database existed. They failed every month from December 2025 through February 2026 with "connection failed." Once the database came online in March, they just worked — no manual intervention needed. This self-healing behavior means you can safely deploy the cron jobs before the database is ready - they'll automatically start working when the database becomes available.&lt;/p&gt;

&lt;p&gt;This is the log retention equivalent of Elasticsearch's ILM — automatic rollover, automatic deletion — but implemented in pure SQL with a scheduling extension.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. The INET Data Type
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;FromIpAddress&lt;/span&gt; &lt;span class="n"&gt;INET&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PostgreSQL's &lt;a href="https://www.postgresql.org/docs/current/datatype-net-types.html" rel="noopener noreferrer"&gt;native &lt;code&gt;INET&lt;/code&gt; type&lt;/a&gt; stores IPv4 and IPv6 addresses with built-in validation and operators. You can query by subnet directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;system_events&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;FromIpAddress&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;=&lt;/span&gt; &lt;span class="s1"&gt;'192.0.2.0/24'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No string parsing, no &lt;code&gt;LIKE '192.0.2.%'&lt;/code&gt; hacks. The &lt;code&gt;&amp;lt;&amp;lt;=&lt;/code&gt; operator means "is contained in subnet" — it's indexable and fast.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Reference Tables with Foreign Keys
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;facilities&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;facility_id&lt;/span&gt; &lt;span class="nb"&gt;SMALLINT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;facility_name&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;priorities&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;priority_id&lt;/span&gt; &lt;span class="nb"&gt;SMALLINT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;priority_name&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;system_events&lt;/span&gt;
    &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;fk_facility&lt;/span&gt; &lt;span class="k"&gt;FOREIGN&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Facility&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;facilities&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;facility_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;system_events&lt;/span&gt;
    &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;fk_priority&lt;/span&gt; &lt;span class="k"&gt;FOREIGN&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Priority&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;priorities&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;priority_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Syslog facility and priority are integers — &lt;code&gt;facility=3&lt;/code&gt;, &lt;code&gt;priority=6&lt;/code&gt;. Without context, those numbers are meaningless. Lookup tables enforce data integrity and make queries self-documenting.&lt;/p&gt;

&lt;p&gt;In this deployment, the facility breakdown tells a clear story about the environment:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Facility&lt;/th&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;th&gt;Percentage&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;daemon&lt;/td&gt;
&lt;td&gt;622,291&lt;/td&gt;
&lt;td&gt;29.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;local7&lt;/td&gt;
&lt;td&gt;493,636&lt;/td&gt;
&lt;td&gt;23.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cron&lt;/td&gt;
&lt;td&gt;325,109&lt;/td&gt;
&lt;td&gt;15.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;auth&lt;/td&gt;
&lt;td&gt;284,703&lt;/td&gt;
&lt;td&gt;13.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;user&lt;/td&gt;
&lt;td&gt;154,686&lt;/td&gt;
&lt;td&gt;7.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The dominance of &lt;code&gt;daemon&lt;/code&gt; and &lt;code&gt;local7&lt;/code&gt; (commonly used by firewalls) suggests this is a network-heavy environment, which I suppose it is—I configured just about everything I could find in my home lab to forward syslog, including several managed switches, OpenWrt WiFi access points, and an OPNsense firewall. The fact that &lt;code&gt;auth&lt;/code&gt; makes up 13.4% of the traffic is an immediate action item for me to look into; that represents hundreds of thousands of SSH sessions, sudo commands, and login attempts across the network.&lt;/p&gt;

&lt;p&gt;The priority distribution looks like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Priority&lt;/th&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;th&gt;Percentage&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;info&lt;/td&gt;
&lt;td&gt;1,240,125&lt;/td&gt;
&lt;td&gt;58.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;notice&lt;/td&gt;
&lt;td&gt;405,596&lt;/td&gt;
&lt;td&gt;19.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;err&lt;/td&gt;
&lt;td&gt;191,932&lt;/td&gt;
&lt;td&gt;9.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;debug&lt;/td&gt;
&lt;td&gt;179,518&lt;/td&gt;
&lt;td&gt;8.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;warning&lt;/td&gt;
&lt;td&gt;103,583&lt;/td&gt;
&lt;td&gt;4.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;While only 9.0% of the 2.12 million messages are errors, that's still nearly 200,000 discrete error events I need to investigate. Additionally, the 8.5% &lt;code&gt;debug&lt;/code&gt; volume indicates that some of these lab devices are running in highly verbose modes. Tracking down and dialing back those debug logs is another thing I'll need to look into to keep the ingestion volume lean.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. A Convenience View
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;v_system_events&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;se&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;facility_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;priority_name&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;system_events&lt;/span&gt; &lt;span class="n"&gt;se&lt;/span&gt;
    &lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;facilities&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;se&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Facility&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;facility_id&lt;/span&gt;
    &lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;priorities&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;se&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Priority&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;priority_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A view that joins the lookup tables means analysts never need to remember that &lt;code&gt;facility_id = 3&lt;/code&gt; means "daemon" — it's right there in the result set. Query the view, not the table.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. Least-Privilege Database User
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;rsyslog&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;LOGIN&lt;/span&gt; &lt;span class="n"&gt;NOSUPERUSER&lt;/span&gt; &lt;span class="n"&gt;INHERIT&lt;/span&gt; &lt;span class="k"&gt;NOCREATEDB&lt;/span&gt; &lt;span class="n"&gt;NOCREATEROLE&lt;/span&gt; &lt;span class="n"&gt;NOREPLICATION&lt;/span&gt; &lt;span class="n"&gt;NOBYPASSRLS&lt;/span&gt;
    &lt;span class="k"&gt;ENCRYPTED&lt;/span&gt; &lt;span class="n"&gt;PASSWORD&lt;/span&gt; &lt;span class="s1"&gt;'SCRAM-SHA-256$...'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;USAGE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;rsyslog&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;USAGE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;SEQUENCE&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;system_events_id_seq&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;rsyslog&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;system_events&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;rsyslog&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rsyslog database user gets exactly what it needs: &lt;code&gt;INSERT&lt;/code&gt; on the events table and &lt;code&gt;SELECT&lt;/code&gt; on the sequence for the serial ID. Nothing more. SCRAM-SHA-256 authentication ensures passwords aren't stored as the legacy MD5 format.&lt;/p&gt;

&lt;h3&gt;
  
  
  8. SQL_ASCII Encoding for Heterogeneous Log Sources
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt; &lt;span class="nv"&gt;"syslog"&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="k"&gt;ENCODING&lt;/span&gt; &lt;span class="s1"&gt;'SQL_ASCII'&lt;/span&gt; &lt;span class="k"&gt;TEMPLATE&lt;/span&gt; &lt;span class="n"&gt;template0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Syslog messages come from dozens of different hosts, each with their own locale and encoding settings. A UTF-8 database would reject any byte sequence that isn't valid UTF-8 — and syslog is full of them. &lt;code&gt;SQL_ASCII&lt;/code&gt; tells PostgreSQL to pass bytes through without validation. No encoding errors from a misbehaving device, no dropped logs.&lt;/p&gt;

&lt;p&gt;The tradeoff is that you need to handle encoding in your application layer. For a log ingestion system that's primarily queried by humans who can tolerate the occasional garbled character, that's a reasonable trade.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Logs Look Like
&lt;/h2&gt;

&lt;p&gt;After running this setup, the data tells a story about the infrastructure. The top log sources:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;dhcpd&lt;/td&gt;
&lt;td&gt;370,683&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;bbstored&lt;/td&gt;
&lt;td&gt;286,399&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;sshd&lt;/td&gt;
&lt;td&gt;182,323&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;radvd&lt;/td&gt;
&lt;td&gt;131,274&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;dhclient&lt;/td&gt;
&lt;td&gt;102,213&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;configd.py&lt;/td&gt;
&lt;td&gt;92,668&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;filterlog&lt;/td&gt;
&lt;td&gt;62,534&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Since we already know this is an OPNsense-heavy home lab, seeing &lt;code&gt;configd.py&lt;/code&gt;, &lt;code&gt;filterlog&lt;/code&gt;, and &lt;code&gt;radvd&lt;/code&gt; bubble to the top makes perfect sense. But the data provides a great reminder of exactly which services are the chattiest. The heavy &lt;code&gt;dhcpd&lt;/code&gt; and &lt;code&gt;dhclient&lt;/code&gt; traffic, for instance, highlights just how many DHCP leases are constantly being renewed by various lab and IoT devices across the network.&lt;/p&gt;

&lt;p&gt;The ingestion rate shows the system ramping up as more hosts were configured:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Date&lt;/th&gt;
&lt;th&gt;Rows&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2026-03-29&lt;/td&gt;
&lt;td&gt;16,375&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-03-30&lt;/td&gt;
&lt;td&gt;11,998&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-03-31&lt;/td&gt;
&lt;td&gt;31,312&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-04-01&lt;/td&gt;
&lt;td&gt;38,699&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-04-02&lt;/td&gt;
&lt;td&gt;39,349&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-04-03&lt;/td&gt;
&lt;td&gt;37,899&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-04-04&lt;/td&gt;
&lt;td&gt;18,489&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;From 16,375 rows in the earlier days to nearly 40,000 per day once all hosts were forwarding. At that rate, each monthly partition accumulates roughly 1 million rows — well within PostgreSQL's comfort zone, especially with partitioning keeping individual table sizes manageable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Useful Queries
&lt;/h2&gt;

&lt;p&gt;Here are practical queries for working with your log data:&lt;/p&gt;

&lt;h3&gt;
  
  
  Error Rate by Host (Last 24 Hours)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;FromHost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;system_events&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;Priority&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;ReceivedAt&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'24 hours'&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Full-Text Search with Highlighted Context
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;ReceivedAt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;FromHost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;EventSource&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;ts_headline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;system_events&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to_tsquery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'authentication &amp;amp; failure'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;message_tsv&lt;/span&gt; &lt;span class="o"&gt;@@&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;ReceivedAt&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Subnet Scan Using INET Type
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;FromHost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;log_count&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;system_events&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;FromIpAddress&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;=&lt;/span&gt; &lt;span class="s1"&gt;'192.0.2.0/24'&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Error Trend Over Time
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;date_trunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'day'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ReceivedAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;day&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;system_events&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;Priority&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Top Error Sources This Week
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;EventSource&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;FromHost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;cnt&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;system_events&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;Priority&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;ReceivedAt&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'7 days'&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Facility Breakdown with Percentages
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;facility_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;cnt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;pct&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;v_system_events&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Recent Errors from Specific Subnet (Optimized by Partial Error Index)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;ReceivedAt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;FromHost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Message&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;system_events&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;Priority&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; 
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;FromIpAddress&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;=&lt;/span&gt; &lt;span class="s1"&gt;'192.0.2.0/24'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;ReceivedAt&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'1 hour'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;ReceivedAt&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Top Generalized Error Messages (De-duplicating PIDs/IPs)
&lt;/h3&gt;

&lt;p&gt;Syslog messages often contain unique numbers (like PIDs, ports, or IP addresses) that make identical errors look like distinct strings. This query uses &lt;code&gt;regexp_replace&lt;/code&gt; to mask numbers, allowing you to group and count the true underlying error patterns.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;regexp_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'[0-9]+'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'X'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'g'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;generalized_message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; 
       &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;error_count&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;system_events&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;Priority&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; 
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;ReceivedAt&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'7 days'&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;generalized_message&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;error_count&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  PostgreSQL Tuning for Log Ingestion
&lt;/h2&gt;

&lt;p&gt;It's worth noting the hardware profile of the environment described in this post: it is running in a small LXC container backed by unremarkable SSDs, with just 4 CPU cores and 8GB of RAM. Despite processing millions of logs and maintaining continuous GIN indexes, the system hovers at around 12% utilization. You do not need massive hardware to make this work.&lt;/p&gt;

&lt;p&gt;To put some real numbers behind that efficiency, here is the actual performance data from this 8GB container:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Index Hit Rate: 99.87%&lt;/strong&gt; (357,517 index scans vs. just 476 sequential scans). This proves the indexing strategy perfectly matches the query patterns. The database planner almost never resorts to scanning full tables.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache Hit Ratio: 84.3%&lt;/strong&gt;. For a write-heavy log database, keeping 84% of accessed blocks in memory is a healthy balance. The SSDs handle the continuous sequential writes, while RAM serves the repetitive index updates and active lookups.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero-Overhead Historical Data&lt;/strong&gt;. PostgreSQL's internal stats show the active month's partition (&lt;code&gt;2026_04&lt;/code&gt;) has been autovacuumed 15 times to keep up with the insert churn, while all historic partitions show exactly 0 runs. Maintenance work is isolated entirely to the active partition, meaning your retained data costs zero CPU cycles to keep around.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That said, the default PostgreSQL configuration isn't optimized for write-heavy log workloads. Here is the real &lt;code&gt;postgresql.conf&lt;/code&gt; block used to hit those numbers on a 4-core, 8GB machine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="c"&gt;# Memory
&lt;/span&gt;&lt;span class="n"&gt;shared_buffers&lt;/span&gt; = &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="n"&gt;GB&lt;/span&gt;                     &lt;span class="c"&gt;# 25% of 8GB RAM
&lt;/span&gt;&lt;span class="n"&gt;maintenance_work_mem&lt;/span&gt; = &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="n"&gt;GB&lt;/span&gt;               &lt;span class="c"&gt;# faster index creation and partition operations
&lt;/span&gt;&lt;span class="n"&gt;work_mem&lt;/span&gt; = &lt;span class="m"&gt;32&lt;/span&gt;&lt;span class="n"&gt;MB&lt;/span&gt;                          &lt;span class="c"&gt;# per-operation memory for sorts and joins
&lt;/span&gt;
&lt;span class="c"&gt;# Disk &amp;amp; Asynchronous Behavior
&lt;/span&gt;&lt;span class="n"&gt;effective_io_concurrency&lt;/span&gt; = &lt;span class="m"&gt;256&lt;/span&gt;           &lt;span class="c"&gt;# highly parallel I/O for SSDs
&lt;/span&gt;&lt;span class="n"&gt;max_worker_processes&lt;/span&gt; = &lt;span class="m"&gt;23&lt;/span&gt;                &lt;span class="c"&gt;# enough workers for background jobs
&lt;/span&gt;&lt;span class="n"&gt;max_parallel_workers&lt;/span&gt; = &lt;span class="m"&gt;4&lt;/span&gt;                 &lt;span class="c"&gt;# matches the 4 CPU cores
&lt;/span&gt;&lt;span class="n"&gt;default_toast_compression&lt;/span&gt; = &lt;span class="n"&gt;lz4&lt;/span&gt;          &lt;span class="c"&gt;# faster text compression on Postgres 14+
&lt;/span&gt;
&lt;span class="c"&gt;# WAL and Checkpoints
&lt;/span&gt;&lt;span class="n"&gt;max_wal_size&lt;/span&gt; = &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="n"&gt;GB&lt;/span&gt;
&lt;span class="n"&gt;min_wal_size&lt;/span&gt; = &lt;span class="m"&gt;512&lt;/span&gt;&lt;span class="n"&gt;MB&lt;/span&gt;
&lt;span class="n"&gt;checkpoint_completion_target&lt;/span&gt; = &lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;9&lt;/span&gt;       &lt;span class="c"&gt;# spread checkpoint I/O out smoothly
&lt;/span&gt;
&lt;span class="c"&gt;# Autovacuum
&lt;/span&gt;&lt;span class="n"&gt;autovacuum_max_workers&lt;/span&gt; = &lt;span class="m"&gt;4&lt;/span&gt;               &lt;span class="c"&gt;# keep up with partition churn
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Magic of LZ4 Compression
&lt;/h3&gt;

&lt;p&gt;One setting in that config deserves a special shoutout: &lt;code&gt;default_toast_compression = lz4&lt;/code&gt;. &lt;/p&gt;

&lt;p&gt;PostgreSQL automatically compresses large text columns behind the scenes (a mechanism called TOAST, or The Oversized-Attribute Storage Technique). By default, it uses an older, slower algorithm called PGLZ. If you are running PostgreSQL 14 or newer, switching this default to LZ4 provides drastically faster compression during log ingestion and faster decompression during queries. For text-heavy workloads like syslog &lt;code&gt;Message&lt;/code&gt; and &lt;code&gt;RawMessage&lt;/code&gt; blobs, this is one of the easiest ways to drop your CPU utilization almost instantly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Properly Sizing &lt;code&gt;work_mem&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Another critical adjustment is &lt;code&gt;work_mem = 32MB&lt;/code&gt;. In a log database, your queries almost exclusively involve massive &lt;code&gt;GROUP BY&lt;/code&gt; and &lt;code&gt;ORDER BY&lt;/code&gt; operations (like aggregating errors or sorting chronologically). &lt;/p&gt;

&lt;p&gt;By default, PostgreSQL's &lt;code&gt;work_mem&lt;/code&gt; is extremely conservative (4MB). If an analytical query requires more memory than that to execute a sort, PostgreSQL will spill the workspace over to your SSD as a temporary file, causing a massive latency spike. Giving those analytical queries 32MB of dedicated RAM ensures that complex groupings (like the regex deduplication query above) execute entirely in memory. In my 8GB container, that regex query groups over 10,000 recent errors in just ~106 milliseconds without ever touching the disk.&lt;/p&gt;

&lt;h2&gt;
  
  
  Backup and Restore
&lt;/h2&gt;

&lt;p&gt;Partitioned tables behave mostly like regular tables for backup purposes, with a few quirks worth knowing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;pg_dump&lt;/code&gt;&lt;/strong&gt; dumps the parent table definition and all partitions. Restoring recreates the full partitioned structure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;pg_basebackup&lt;/code&gt;&lt;/strong&gt; works normally — partitions are just tables on disk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restoring a single partition&lt;/strong&gt; — you can &lt;code&gt;pg_dump&lt;/code&gt; a specific partition table and restore it independently, useful for recovering from a data archive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;pg_restore --jobs&lt;/code&gt;&lt;/strong&gt; — parallel restore works across partitions, speeding up large restores.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For compliance archival, you can &lt;code&gt;pg_dump&lt;/code&gt; specific partitions: &lt;code&gt;pg_dump -t system_events_2026_03 &amp;gt; march2026.sql&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;One gotcha: if you restore into a database that already has partitions, you'll get conflicts. Always restore into a fresh database or drop existing partitions first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Troubleshooting
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Queue Full
&lt;/h3&gt;

&lt;p&gt;If rsyslog's queue fills up (20,000 messages), new messages are dropped. Check with:&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="c"&gt;# Check rsyslog queue status&lt;/span&gt;
journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; rsyslog | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"queue"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mitigate by increasing &lt;code&gt;queue.size&lt;/code&gt; or adding disk-backed queuing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;queue&lt;/span&gt;.&lt;span class="n"&gt;type&lt;/span&gt;=&lt;span class="s2"&gt;"disk"&lt;/span&gt;
&lt;span class="n"&gt;queue&lt;/span&gt;.&lt;span class="n"&gt;spoolDirectory&lt;/span&gt;=&lt;span class="s2"&gt;"/var/spool/rsyslog"&lt;/span&gt;
&lt;span class="n"&gt;queue&lt;/span&gt;.&lt;span class="n"&gt;maxDiskSpace&lt;/span&gt;=&lt;span class="s2"&gt;"1g"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  pg_cron Job Failed
&lt;/h3&gt;

&lt;p&gt;Check the job run history:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;jobid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;runid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;return_message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;start_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;end_time&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;cron&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;job_run_details&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;start_time&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Common failures:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"connection failed"&lt;/strong&gt; — the target database wasn't reachable. Check that the database exists and the role has permissions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"no partition of relation found"&lt;/strong&gt; — a partition is missing. Run &lt;code&gt;CALL public.create_future_partitions('system_events', 3);&lt;/code&gt; manually.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Permission denied&lt;/strong&gt; — ensure the &lt;code&gt;rsyslog&lt;/code&gt; role has the necessary grants on new partitions.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Partition Missing on Insert
&lt;/h3&gt;

&lt;p&gt;If rsyslog logs an error like "no partition of relation 'system_events' found for row," the partition for that date range doesn't exist yet. This happens if &lt;code&gt;pg_cron&lt;/code&gt; hasn't run or failed. Fix it manually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CALL&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;create_monthly_partition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'system_events'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Slow Queries
&lt;/h3&gt;

&lt;p&gt;Use &lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;/code&gt; to check if partition pruning is working:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;EXPLAIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ANALYZE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BUFFERS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;system_events&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;ReceivedAt&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="s1"&gt;'2026-03-01'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;ReceivedAt&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="s1"&gt;'2026-04-01'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look for the partitions being scanned in the plan. If all partitions appear, your query isn't filtering on the partition key.&lt;/p&gt;

&lt;p&gt;Check for unused indexes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;indexrelname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;idx_scan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;pg_size_pretty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_relation_size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;indexrelid&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;index_size&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_user_indexes&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;relname&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'system_events_%'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;idx_scan&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;idx_scan&lt;/code&gt; is 0 after weeks of operation, consider dropping that index.&lt;/p&gt;

&lt;h3&gt;
  
  
  High CPU During Partition Creation
&lt;/h3&gt;

&lt;p&gt;Creating many partitions at once can be CPU-intensive. The &lt;code&gt;create_future_partitions&lt;/code&gt; procedure creates them sequentially to spread the load. If you need to create many partitions manually, consider adding a small delay between calls or running during off-peak hours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Comparison: PostgreSQL vs. Elasticsearch
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;PostgreSQL&lt;/th&gt;
&lt;th&gt;Elasticsearch&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Time-based data split&lt;/td&gt;
&lt;td&gt;Range partitions&lt;/td&gt;
&lt;td&gt;Time-based indices&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pruning&lt;/td&gt;
&lt;td&gt;Partition pruning at plan time&lt;/td&gt;
&lt;td&gt;Shard routing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retention&lt;/td&gt;
&lt;td&gt;Instant &lt;code&gt;DROP TABLE&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Delete index&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Automation&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;pg_cron&lt;/code&gt; + stored procedures&lt;/td&gt;
&lt;td&gt;Index Lifecycle Management (ILM)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IP queries&lt;/td&gt;
&lt;td&gt;Native &lt;code&gt;INET&lt;/code&gt; type with subnet operators&lt;/td&gt;
&lt;td&gt;IP field type&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Self-documenting queries&lt;/td&gt;
&lt;td&gt;Lookup tables + foreign keys + views&lt;/td&gt;
&lt;td&gt;Index templates + Kibana&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ingestion resilience&lt;/td&gt;
&lt;td&gt;rsyslog queue buffering&lt;/td&gt;
&lt;td&gt;Logstash queue&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data integrity&lt;/td&gt;
&lt;td&gt;ACID, foreign keys&lt;/td&gt;
&lt;td&gt;Eventual consistency&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Query language&lt;/td&gt;
&lt;td&gt;SQL&lt;/td&gt;
&lt;td&gt;Query DSL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full-text search&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;tsvector&lt;/code&gt; + GIN index&lt;/td&gt;
&lt;td&gt;Native, highly optimized&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Aggregations at scale&lt;/td&gt;
&lt;td&gt;Good up to ~100M rows&lt;/td&gt;
&lt;td&gt;Excellent at petabyte scale&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Operational Complexity&lt;/td&gt;
&lt;td&gt;Low (uses existing infrastructure, standard SQL)&lt;/td&gt;
&lt;td&gt;High (separate cluster, specialized knowledge needed)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;PostgreSQL wins at structured log analysis with relational context, ACID guarantees, and operational simplicity. Elasticsearch wins at full-text search and aggregations at massive scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to Go From Here
&lt;/h2&gt;

&lt;p&gt;This setup is production-ready as-is, but there are natural enhancements you can add to suit your needs:&lt;/p&gt;

&lt;h3&gt;
  
  
  Materialized Views for Dashboards
&lt;/h3&gt;

&lt;p&gt;A materialized view is a pre-computed query result stored on disk — like a cached query. Unlike a regular view that re-runs every time, a materialized view stores the results and you refresh it on a schedule.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;MATERIALIZED&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;mv_daily_error_counts&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;date_trunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'day'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ReceivedAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;day&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;FromHost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;EventSource&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Priority&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;error_count&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;system_events&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;Priority&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;mv_daily_error_counts&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;day&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;FromHost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;EventSource&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Priority&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Refresh it on a schedule:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;REFRESH&lt;/span&gt; &lt;span class="n"&gt;MATERIALIZED&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;CONCURRENTLY&lt;/span&gt; &lt;span class="n"&gt;mv_daily_error_counts&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;CONCURRENTLY&lt;/code&gt; keyword means the view stays queryable during refresh — no downtime for dashboards. Schedule it via &lt;code&gt;pg_cron&lt;/code&gt; every hour and you have a near-real-time error dashboard backed by a tiny table instead of scanning millions of rows.&lt;/p&gt;

&lt;h3&gt;
  
  
  Composite Indexes for Specific Query Patterns
&lt;/h3&gt;

&lt;p&gt;A composite index covers multiple columns in a single structure. The column order matters because of the &lt;strong&gt;leftmost prefix rule&lt;/strong&gt; — an index on &lt;code&gt;(A, B, C)&lt;/code&gt; supports queries on &lt;code&gt;A&lt;/code&gt;, &lt;code&gt;A+B&lt;/code&gt;, and &lt;code&gt;A+B+C&lt;/code&gt;, but not on &lt;code&gt;B&lt;/code&gt; alone or &lt;code&gt;C&lt;/code&gt; alone.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_system_events_host_time&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;system_events&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;FromHost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ReceivedAt&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_system_events_source_time&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;system_events&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;EventSource&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ReceivedAt&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These support your most common queries efficiently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- "Show me all logs from this host, newest first"&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;system_events&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;FromHost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'firewall-primary'&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;ReceivedAt&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- "Show me all sshd logs from today"&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;system_events&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;EventSource&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'sshd'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;ReceivedAt&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'1 day'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Horizontal Scale-Out
&lt;/h3&gt;

&lt;p&gt;If your ingestion volume ever outgrows a single node, this architecture scales out cleanly at both layers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rsyslog Scale-Out:&lt;/strong&gt; You can run multiple instances of Rsyslog behind a network load balancer (like HAProxy or F5) to distribute the incoming TCP/UDP traffic. Because the Rsyslog instances are completely stateless log routers, you can scale them horizontally to handle massive broadcast storms, with all nodes writing concurrently to the central database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PostgreSQL Scale-Out:&lt;/strong&gt; To handle high dashboard concurrency or complex analytical queries, you can easily spin up &lt;strong&gt;Read Replicas&lt;/strong&gt; to offload read pressure from your primary database. If your write volume eventually exceeds a single node's capacity, you can shard your time-partitioned tables across a cluster of worker nodes using distributed SQL or time-series extensions, unlocking petabyte-scale retention.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Other Enhancements
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;JSONB structured parsing&lt;/strong&gt; — Parse common log formats (nginx, auth, systemd) into structured JSONB columns for targeted queries instead of &lt;code&gt;LIKE '%pattern%'&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Row-Level Security&lt;/strong&gt; — Restrict log access by team, host group, or customer using PostgreSQL RLS policies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data Archiving&lt;/strong&gt; — &lt;code&gt;COPY TO&lt;/code&gt; old partitions to an external file before dropping them, for compliance retention without bloating the database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grafana dashboards&lt;/strong&gt; — Connect PostgreSQL directly as a datasource for real-time log dashboards, or use Metabase for self-service exploration by non-SQL users.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TLS Encryption&lt;/strong&gt; — Standard syslog over TCP/UDP is unencrypted plaintext. If you are forwarding logs across untrusted networks and your client devices support it, configure rsyslog to use the TLS module to encrypt your log traffic in transit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The beauty of this approach is that each enhancement uses PostgreSQL features you're already running. No new infrastructure, no new operational burden — just SQL.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Prerequisites:&lt;/strong&gt; By default, Linux distributions do not ship with the PostgreSQL or RELP modules. For Debian/Ubuntu-based systems, install them first (omit &lt;code&gt;rsyslog-relp&lt;/code&gt; if you are sticking with TCP/UDP):&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;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;rsyslog-pgsql rsyslog-relp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Create the database and schema with the provided &lt;code&gt;syslog.sql&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Drop the rsyslog server config into &lt;code&gt;/etc/rsyslog.conf&lt;/code&gt; (or &lt;code&gt;/etc/rsyslog.d/&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Configure clients with the forwarding config&lt;/li&gt;
&lt;li&gt;Restart rsyslog: &lt;code&gt;systemctl restart rsyslog&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Verify: &lt;code&gt;SELECT count(*) FROM v_system_events;&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The whole pipeline is self-maintaining: partitions roll forward and backward automatically, old data vanishes without &lt;code&gt;DELETE&lt;/code&gt; overhead, the queue absorbs transient failures, and full-text search is always in sync. For a log ingestion system, that's exactly what you want.&lt;/p&gt;




&lt;h2&gt;
  
  
  Appendix
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Problem with Flat File Logs
&lt;/h3&gt;

&lt;p&gt;Flat file syslog works fine until you need to answer questions like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Show me all authentication failures from the last 48 hours across all hosts"&lt;/li&gt;
&lt;li&gt;"How many error-level messages did our firewall generate this week?"&lt;/li&gt;
&lt;li&gt;"What's the trend in disk warnings over the past three months?"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With flat files, these require &lt;code&gt;grep&lt;/code&gt;, &lt;code&gt;awk&lt;/code&gt;, and a lot of patience. With a database, they're a single SQL query.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why Not Elasticsearch?
&lt;/h3&gt;

&lt;p&gt;Elasticsearch is purpose-built for log aggregation, and it excels at full-text search and aggregations at massive scale. But it comes with real costs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A separate cluster to provision, monitor, and upgrade&lt;/li&gt;
&lt;li&gt;For small-to-medium deployments, Elasticsearch's operational overhead can still be significant compared to adding tables to an existing PostgreSQL instance&lt;/li&gt;
&lt;li&gt;Index lifecycle management (ILM) to configure and maintain&lt;/li&gt;
&lt;li&gt;No ACID (Atomicity, Consistency, Isolation, Durability) guarantees — log ingestion can lose data during restarts&lt;/li&gt;
&lt;li&gt;Query Domain-Specific Language (DSL) that's powerful but has its own learning curve&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;PostgreSQL, on the other hand, is probably already running in your stack. It gives you ACID guarantees, relational joins, foreign keys, and a query language your team already knows. With the right schema design, it handles log ingestion and retention surprisingly well.&lt;/p&gt;

&lt;p&gt;This isn't to say PostgreSQL replaces Elasticsearch at petabyte scale. But for small-to-medium deployments — a few million rows, dozens of hosts — a well-designed PostgreSQL setup can cover 80% of the use cases with 20% of the operational overhead.&lt;/p&gt;




&lt;h2&gt;
  
  
  Full SQL Schema
&lt;/h2&gt;

&lt;p&gt;Here's the complete schema for reference. Save this as &lt;code&gt;syslog.sql&lt;/code&gt; and run it with &lt;code&gt;psql -f syslog.sql&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt; &lt;span class="nv"&gt;"syslog"&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="k"&gt;ENCODING&lt;/span&gt; &lt;span class="s1"&gt;'SQL_ASCII'&lt;/span&gt; &lt;span class="k"&gt;TEMPLATE&lt;/span&gt; &lt;span class="n"&gt;template0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="k"&gt;connect&lt;/span&gt; &lt;span class="n"&gt;syslog&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;system_events&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;ID&lt;/span&gt; &lt;span class="nb"&gt;SERIAL&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;DeviceReportedTime&lt;/span&gt; &lt;span class="nb"&gt;TIMESTAMP&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="nb"&gt;TIME&lt;/span&gt; &lt;span class="k"&gt;ZONE&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;EventID&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;EventLogType&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;EventSource&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;Facility&lt;/span&gt; &lt;span class="nb"&gt;SMALLINT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;FromHost&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;FromPort&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;FromIpAddress&lt;/span&gt; &lt;span class="n"&gt;INET&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;HostName&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Message&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Priority&lt;/span&gt; &lt;span class="nb"&gt;SMALLINT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;RawMessage&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ReceivedAt&lt;/span&gt; &lt;span class="nb"&gt;TIMESTAMP&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="nb"&gt;TIME&lt;/span&gt; &lt;span class="k"&gt;ZONE&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;RFC5424AppName&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;RFC5424MsgID&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;RFC5424ProcID&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;RFC5424ProtocolVersion&lt;/span&gt; &lt;span class="nb"&gt;SMALLINT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;RFC5424StructuredData&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;SysLogTag&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;message_tsv&lt;/span&gt; &lt;span class="n"&gt;tsvector&lt;/span&gt; &lt;span class="k"&gt;GENERATED&lt;/span&gt; &lt;span class="n"&gt;ALWAYS&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;to_tsvector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="n"&gt;STORED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ReceivedAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;RANGE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ReceivedAt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- BRIN indexes for time-ordered columns (much smaller than B-tree for append-only data)&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_system_events_receivedat_brin&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;system_events&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;brin&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ReceivedAt&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_system_events_devicereportedtime_brin&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;system_events&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;brin&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DeviceReportedTime&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;-- B-tree indexes for common query filters&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_system_events_facility&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;system_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Facility&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_system_events_priority&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;system_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Priority&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_system_events_fromhost&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;system_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;FromHost&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_system_events_eventsource&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;system_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;EventSource&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;-- Partial index for error-level logs only (Priority 0-3: emerg, alert, crit, err)&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_system_events_errors&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;system_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;FromHost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;EventSource&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ReceivedAt&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;Priority&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- Full-text search index on Message&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_system_events_message_fts&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;system_events&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;gin&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message_tsv&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Create table for Facility mappings&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;facilities&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;facility_id&lt;/span&gt; &lt;span class="nb"&gt;SMALLINT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;facility_name&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Insert common syslog facility values&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;facilities&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;facility_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;facility_name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'kern'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'user'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'mail'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'daemon'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'auth'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'syslog'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'lpr'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'news'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'uucp'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'cron'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'authpriv'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'ftp'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'ntp'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;13&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'security'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'console'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'solaris-cron'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'local0'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;17&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'local1'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'local2'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;19&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'local3'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'local4'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;21&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'local5'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;22&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'local6'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;23&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'local7'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Create table for Priority mappings&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;priorities&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;priority_id&lt;/span&gt; &lt;span class="nb"&gt;SMALLINT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;priority_name&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Insert common syslog priority values&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;priorities&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;priority_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;priority_name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'emerg'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'alert'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'crit'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'err'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'warning'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'notice'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'info'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'debug'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Add foreign key constraints&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;system_events&lt;/span&gt;
    &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;fk_facility&lt;/span&gt; &lt;span class="k"&gt;FOREIGN&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Facility&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;facilities&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;facility_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;system_events&lt;/span&gt;
    &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;fk_priority&lt;/span&gt; &lt;span class="k"&gt;FOREIGN&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Priority&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;priorities&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;priority_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- A convenient view&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;v_system_events&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;se&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;facility_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;priority_name&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;system_events&lt;/span&gt; &lt;span class="n"&gt;se&lt;/span&gt;
    &lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;facilities&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;se&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Facility&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;facility_id&lt;/span&gt;
    &lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;priorities&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;se&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Priority&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;priority_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Create initial partitions&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;system_events_2026_03&lt;/span&gt; &lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;OF&lt;/span&gt; &lt;span class="n"&gt;system_events&lt;/span&gt;
    &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2026-03-01'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2026-04-01'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;system_events_2026_04&lt;/span&gt; &lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;OF&lt;/span&gt; &lt;span class="n"&gt;system_events&lt;/span&gt;
    &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2026-04-01'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2026-05-01'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;system_events_2026_05&lt;/span&gt; &lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;OF&lt;/span&gt; &lt;span class="n"&gt;system_events&lt;/span&gt;
    &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2026-05-01'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2026-06-01'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Procedure to create a new monthly partition&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;PROCEDURE&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;create_monthly_partition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p_table_name&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;year&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;month&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;DECLARE&lt;/span&gt;
    &lt;span class="n"&gt;partition_name&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;start_date&lt;/span&gt; &lt;span class="nb"&gt;DATE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;end_date&lt;/span&gt; &lt;span class="nb"&gt;DATE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
    &lt;span class="n"&gt;start_date&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;make_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;end_date&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;start_date&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'1 month'&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;DATE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;partition_name&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'%s_%s_%s'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p_table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lpad&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;month&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'0'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_tables&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;tablename&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;partition_name&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;schemaname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt;
        &lt;span class="k"&gt;EXECUTE&lt;/span&gt; &lt;span class="n"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s1"&gt;'CREATE TABLE public.%I PARTITION OF public.%I FOR VALUES FROM (%L) TO (%L)'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;partition_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p_table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;start_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;end_date&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;RAISE&lt;/span&gt; &lt;span class="n"&gt;NOTICE&lt;/span&gt; &lt;span class="s1"&gt;'Created partition public.%'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;partition_name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;ELSE&lt;/span&gt;
        &lt;span class="n"&gt;RAISE&lt;/span&gt; &lt;span class="n"&gt;NOTICE&lt;/span&gt; &lt;span class="s1"&gt;'Partition public.% already exists, skipping'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;partition_name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;EXCEPTION&lt;/span&gt;
    &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;OTHERS&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt;
        &lt;span class="n"&gt;RAISE&lt;/span&gt; &lt;span class="n"&gt;WARNING&lt;/span&gt; &lt;span class="s1"&gt;'Error creating partition public.%: %'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;partition_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SQLERRM&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;RAISE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Procedure to create partitions for the next N months&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;PROCEDURE&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;create_future_partitions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p_table_name&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;months_ahead&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;DECLARE&lt;/span&gt;
    &lt;span class="k"&gt;current_date&lt;/span&gt; &lt;span class="nb"&gt;DATE&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;CURRENT_DATE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;target_date&lt;/span&gt; &lt;span class="nb"&gt;DATE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;target_year&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;target_month&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
    &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;..&lt;/span&gt;&lt;span class="n"&gt;months_ahead&lt;/span&gt; &lt;span class="n"&gt;LOOP&lt;/span&gt;
        &lt;span class="n"&gt;target_date&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;current_date&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'1 month'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;target_year&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;EXTRACT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;YEAR&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;target_date&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;target_month&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;EXTRACT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;MONTH&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;target_date&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;CALL&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;create_monthly_partition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p_table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target_year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target_month&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="n"&gt;LOOP&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;EXCEPTION&lt;/span&gt;
    &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;OTHERS&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt;
        &lt;span class="n"&gt;RAISE&lt;/span&gt; &lt;span class="n"&gt;WARNING&lt;/span&gt; &lt;span class="s1"&gt;'Error in create_future_partitions for %: %'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p_table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SQLERRM&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;RAISE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Procedure to drop partitions older than a specified interval&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;PROCEDURE&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;drop_old_partitions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p_table_name&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;retention_interval&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;DECLARE&lt;/span&gt;
    &lt;span class="n"&gt;partition_name&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;dropped_count&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
    &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="n"&gt;partition_name&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;tables&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;table_name&lt;/span&gt;
        &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tables&lt;/span&gt;
        &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;tables&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;table_name&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="n"&gt;p_table_name&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;'_%'&lt;/span&gt;
        &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;tables&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;table_name&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;p_table_name&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;'_'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;to_char&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;CURRENT_DATE&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;retention_interval&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'YYYY_MM'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;tables&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;table_schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;LOOP&lt;/span&gt;
        &lt;span class="k"&gt;EXECUTE&lt;/span&gt; &lt;span class="s1"&gt;'DROP TABLE IF EXISTS public.'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;quote_ident&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;partition_name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;RAISE&lt;/span&gt; &lt;span class="n"&gt;NOTICE&lt;/span&gt; &lt;span class="s1"&gt;'Dropped partition public.%'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;partition_name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;dropped_count&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dropped_count&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="n"&gt;LOOP&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="n"&gt;dropped_count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt;
        &lt;span class="n"&gt;RAISE&lt;/span&gt; &lt;span class="n"&gt;NOTICE&lt;/span&gt; &lt;span class="s1"&gt;'No partitions dropped for % older than %'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p_table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;retention_interval&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;EXCEPTION&lt;/span&gt;
    &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;OTHERS&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt;
        &lt;span class="n"&gt;RAISE&lt;/span&gt; &lt;span class="n"&gt;WARNING&lt;/span&gt; &lt;span class="s1"&gt;'Error dropping partitions for %: %'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p_table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SQLERRM&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;RAISE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CALL&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;create_future_partitions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'system_events'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CALL&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;drop_old_partitions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'system_events'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'3 months'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;postgres&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;system_events&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;postgres&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;EXECUTE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;PROCEDURE&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;create_monthly_partition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;postgres&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;EXECUTE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;PROCEDURE&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;create_future_partitions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;postgres&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;EXECUTE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;PROCEDURE&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;drop_old_partitions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;postgres&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;USAGE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;rsyslog&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;USAGE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;SEQUENCE&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;system_events_id_seq&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;rsyslog&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;system_events&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;rsyslog&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Run this in the postgres database where pg_cron is installed&lt;/span&gt;
&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="k"&gt;connect&lt;/span&gt; &lt;span class="n"&gt;postgres&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;pg_cron&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- pg_cron job to delete partitions older than 3 months&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;cron&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;unschedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'drop_old_system_events_partitions'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;cron&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;schedule_in_database&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'drop_old_system_events_partitions'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'0 14 1 * *'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="k"&gt;CALL&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;drop_old_partitions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'system_events'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'3 months'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'syslog'&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- pg_cron job to create partitions for the next 3 months&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;cron&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;unschedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'create_future_system_events_partitions'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;cron&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;schedule_in_database&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'create_future_system_events_partitions'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'5 14 1 * *'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="k"&gt;CALL&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;create_future_partitions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'system_events'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'syslog'&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Full Rsyslog Server Configuration
&lt;/h2&gt;

&lt;p&gt;Save this to &lt;code&gt;/etc/rsyslog.conf&lt;/code&gt; (or inside &lt;code&gt;/etc/rsyslog.d/&lt;/code&gt; depending on your OS) on the server receiving logs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="c"&gt;# Load required modules
&lt;/span&gt;&lt;span class="n"&gt;module&lt;/span&gt;(&lt;span class="n"&gt;load&lt;/span&gt;=&lt;span class="s2"&gt;"imudp"&lt;/span&gt;) &lt;span class="c"&gt;# UDP syslog reception
&lt;/span&gt;&lt;span class="n"&gt;module&lt;/span&gt;(&lt;span class="n"&gt;load&lt;/span&gt;=&lt;span class="s2"&gt;"imtcp"&lt;/span&gt;) &lt;span class="c"&gt;# TCP syslog reception
&lt;/span&gt;&lt;span class="n"&gt;module&lt;/span&gt;(&lt;span class="n"&gt;load&lt;/span&gt;=&lt;span class="s2"&gt;"imrelp"&lt;/span&gt;) &lt;span class="c"&gt;# RELP syslog reception
&lt;/span&gt;
&lt;span class="c"&gt;# Define inputs
&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;(
    &lt;span class="n"&gt;type&lt;/span&gt;=&lt;span class="s2"&gt;"imudp"&lt;/span&gt;
    &lt;span class="n"&gt;port&lt;/span&gt;=&lt;span class="s2"&gt;"514"&lt;/span&gt;
    &lt;span class="n"&gt;rateLimit&lt;/span&gt;.&lt;span class="n"&gt;interval&lt;/span&gt;=&lt;span class="s2"&gt;"60"&lt;/span&gt;
    &lt;span class="n"&gt;rateLimit&lt;/span&gt;.&lt;span class="n"&gt;burst&lt;/span&gt;=&lt;span class="s2"&gt;"2000"&lt;/span&gt;
)

&lt;span class="n"&gt;input&lt;/span&gt;(
    &lt;span class="n"&gt;type&lt;/span&gt;=&lt;span class="s2"&gt;"imtcp"&lt;/span&gt;
    &lt;span class="n"&gt;port&lt;/span&gt;=&lt;span class="s2"&gt;"514"&lt;/span&gt;
)

&lt;span class="n"&gt;input&lt;/span&gt;(
    &lt;span class="n"&gt;type&lt;/span&gt;=&lt;span class="s2"&gt;"imrelp"&lt;/span&gt;
    &lt;span class="n"&gt;port&lt;/span&gt;=&lt;span class="s2"&gt;"2514"&lt;/span&gt;
    &lt;span class="n"&gt;maxDataSize&lt;/span&gt;=&lt;span class="s2"&gt;"10k"&lt;/span&gt;
)

&lt;span class="c"&gt;# Load PostgreSQL output module
&lt;/span&gt;&lt;span class="n"&gt;module&lt;/span&gt;(&lt;span class="n"&gt;load&lt;/span&gt;=&lt;span class="s2"&gt;"ompgsql"&lt;/span&gt;)

&lt;span class="c"&gt;# Define the SQL template
&lt;/span&gt;&lt;span class="n"&gt;template&lt;/span&gt;(
    &lt;span class="n"&gt;name&lt;/span&gt;=&lt;span class="s2"&gt;"LogToPgSQL"&lt;/span&gt; &lt;span class="n"&gt;type&lt;/span&gt;=&lt;span class="s2"&gt;"list"&lt;/span&gt; &lt;span class="n"&gt;option&lt;/span&gt;.&lt;span class="n"&gt;stdsql&lt;/span&gt;=&lt;span class="s2"&gt;"on"&lt;/span&gt; ) {
    &lt;span class="n"&gt;constant&lt;/span&gt;(&lt;span class="n"&gt;value&lt;/span&gt;=&lt;span class="s2"&gt;"INSERT INTO system_events (DeviceReportedTime, EventID, EventLogType, EventSource, Facility, FromHost, FromIpAddress, HostName, Message, Priority, RawMessage, ReceivedAt, SysLogTag) VALUES ('"&lt;/span&gt;)
    &lt;span class="n"&gt;property&lt;/span&gt;(&lt;span class="n"&gt;name&lt;/span&gt;=&lt;span class="s2"&gt;"timereported"&lt;/span&gt; &lt;span class="n"&gt;dateFormat&lt;/span&gt;=&lt;span class="s2"&gt;"pgsql"&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;.&lt;span class="n"&gt;inUTC&lt;/span&gt;=&lt;span class="s2"&gt;"on"&lt;/span&gt;)
    &lt;span class="n"&gt;constant&lt;/span&gt;(&lt;span class="n"&gt;value&lt;/span&gt;=&lt;span class="s2"&gt;"', '"&lt;/span&gt;)
    &lt;span class="n"&gt;property&lt;/span&gt;(&lt;span class="n"&gt;name&lt;/span&gt;=&lt;span class="s2"&gt;"msgid"&lt;/span&gt;)
    &lt;span class="n"&gt;constant&lt;/span&gt;(&lt;span class="n"&gt;value&lt;/span&gt;=&lt;span class="s2"&gt;"', 'syslog', '"&lt;/span&gt;)
    &lt;span class="n"&gt;property&lt;/span&gt;(&lt;span class="n"&gt;name&lt;/span&gt;=&lt;span class="s2"&gt;"programname"&lt;/span&gt;)
    &lt;span class="n"&gt;constant&lt;/span&gt;(&lt;span class="n"&gt;value&lt;/span&gt;=&lt;span class="s2"&gt;"', '"&lt;/span&gt;)
    &lt;span class="n"&gt;property&lt;/span&gt;(&lt;span class="n"&gt;name&lt;/span&gt;=&lt;span class="s2"&gt;"syslogfacility"&lt;/span&gt;)
    &lt;span class="n"&gt;constant&lt;/span&gt;(&lt;span class="n"&gt;value&lt;/span&gt;=&lt;span class="s2"&gt;"', '"&lt;/span&gt;)
    &lt;span class="n"&gt;property&lt;/span&gt;(&lt;span class="n"&gt;name&lt;/span&gt;=&lt;span class="s2"&gt;"fromhost"&lt;/span&gt;)
    &lt;span class="n"&gt;constant&lt;/span&gt;(&lt;span class="n"&gt;value&lt;/span&gt;=&lt;span class="s2"&gt;"', '"&lt;/span&gt;)
    &lt;span class="n"&gt;property&lt;/span&gt;(&lt;span class="n"&gt;name&lt;/span&gt;=&lt;span class="s2"&gt;"fromhost-ip"&lt;/span&gt;)
    &lt;span class="n"&gt;constant&lt;/span&gt;(&lt;span class="n"&gt;value&lt;/span&gt;=&lt;span class="s2"&gt;"', '"&lt;/span&gt;)
    &lt;span class="n"&gt;property&lt;/span&gt;(&lt;span class="n"&gt;name&lt;/span&gt;=&lt;span class="s2"&gt;"hostname"&lt;/span&gt;)
    &lt;span class="n"&gt;constant&lt;/span&gt;(&lt;span class="n"&gt;value&lt;/span&gt;=&lt;span class="s2"&gt;"', '"&lt;/span&gt;)
    &lt;span class="n"&gt;property&lt;/span&gt;(&lt;span class="n"&gt;name&lt;/span&gt;=&lt;span class="s2"&gt;"msg"&lt;/span&gt; &lt;span class="n"&gt;escape&lt;/span&gt;=&lt;span class="s2"&gt;"sql"&lt;/span&gt;)
    &lt;span class="n"&gt;constant&lt;/span&gt;(&lt;span class="n"&gt;value&lt;/span&gt;=&lt;span class="s2"&gt;"', "&lt;/span&gt;)
    &lt;span class="n"&gt;property&lt;/span&gt;(&lt;span class="n"&gt;name&lt;/span&gt;=&lt;span class="s2"&gt;"syslogpriority"&lt;/span&gt;)
    &lt;span class="n"&gt;constant&lt;/span&gt;(&lt;span class="n"&gt;value&lt;/span&gt;=&lt;span class="s2"&gt;", '"&lt;/span&gt;)
    &lt;span class="n"&gt;property&lt;/span&gt;(&lt;span class="n"&gt;name&lt;/span&gt;=&lt;span class="s2"&gt;"rawmsg"&lt;/span&gt; &lt;span class="n"&gt;escape&lt;/span&gt;=&lt;span class="s2"&gt;"sql"&lt;/span&gt;)
    &lt;span class="n"&gt;constant&lt;/span&gt;(&lt;span class="n"&gt;value&lt;/span&gt;=&lt;span class="s2"&gt;"', '"&lt;/span&gt;)
    &lt;span class="n"&gt;property&lt;/span&gt;(&lt;span class="n"&gt;name&lt;/span&gt;=&lt;span class="s2"&gt;"timegenerated"&lt;/span&gt; &lt;span class="n"&gt;dateFormat&lt;/span&gt;=&lt;span class="s2"&gt;"pgsql"&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;.&lt;span class="n"&gt;inUTC&lt;/span&gt;=&lt;span class="s2"&gt;"on"&lt;/span&gt;)
    &lt;span class="n"&gt;constant&lt;/span&gt;(&lt;span class="n"&gt;value&lt;/span&gt;=&lt;span class="s2"&gt;"', '"&lt;/span&gt;)
    &lt;span class="n"&gt;property&lt;/span&gt;(&lt;span class="n"&gt;name&lt;/span&gt;=&lt;span class="s2"&gt;"syslogtag"&lt;/span&gt;)
    &lt;span class="n"&gt;constant&lt;/span&gt;(&lt;span class="n"&gt;value&lt;/span&gt;=&lt;span class="s2"&gt;"')"&lt;/span&gt;)
}

&lt;span class="c"&gt;# Action to write to PostgreSQL
&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;(
    &lt;span class="n"&gt;type&lt;/span&gt;=&lt;span class="s2"&gt;"ompgsql"&lt;/span&gt; &lt;span class="n"&gt;server&lt;/span&gt;=&lt;span class="s2"&gt;"localhost"&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt;=&lt;span class="s2"&gt;"rsyslog"&lt;/span&gt; &lt;span class="n"&gt;pass&lt;/span&gt;=&lt;span class="s2"&gt;"YOUR_STRONG_PASSWORD_HERE"&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;=&lt;span class="s2"&gt;"syslog"&lt;/span&gt; &lt;span class="n"&gt;template&lt;/span&gt;=&lt;span class="s2"&gt;"LogToPgSQL"&lt;/span&gt;

    &lt;span class="n"&gt;queue&lt;/span&gt;.&lt;span class="n"&gt;type&lt;/span&gt;=&lt;span class="s2"&gt;"linkedList"&lt;/span&gt;
    &lt;span class="n"&gt;queue&lt;/span&gt;.&lt;span class="n"&gt;size&lt;/span&gt;=&lt;span class="s2"&gt;"20000"&lt;/span&gt;
    &lt;span class="n"&gt;queue&lt;/span&gt;.&lt;span class="n"&gt;workerThreads&lt;/span&gt;=&lt;span class="s2"&gt;"2"&lt;/span&gt;
)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Full Rsyslog Client Configuration
&lt;/h2&gt;

&lt;p&gt;Save this to &lt;code&gt;/etc/rsyslog.d/99-forward.conf&lt;/code&gt; on any Linux client you want to forward logs from. Be sure to replace &lt;code&gt;192.0.2.1&lt;/code&gt; with the actual IP of your Rsyslog server.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="c"&gt;# Forward all logs via TCP to the central server
&lt;/span&gt;*.* &lt;span class="n"&gt;action&lt;/span&gt;(
    &lt;span class="n"&gt;type&lt;/span&gt;=&lt;span class="s2"&gt;"omfwd"&lt;/span&gt;
    &lt;span class="n"&gt;target&lt;/span&gt;=&lt;span class="s2"&gt;"192.0.2.1"&lt;/span&gt;
    &lt;span class="n"&gt;port&lt;/span&gt;=&lt;span class="s2"&gt;"514"&lt;/span&gt;
    &lt;span class="n"&gt;protocol&lt;/span&gt;=&lt;span class="s2"&gt;"tcp"&lt;/span&gt;
    &lt;span class="n"&gt;template&lt;/span&gt;=&lt;span class="s2"&gt;"RSYSLOG_SyslogProtocol23Format"&lt;/span&gt; &lt;span class="c"&gt;# RFC 5424 format
&lt;/span&gt;)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>sre</category>
      <category>postgres</category>
      <category>rsyslog</category>
      <category>syslog</category>
    </item>
    <item>
      <title>Manage the health of your CLI tools at scale</title>
      <dc:creator>Dean Hamstead</dc:creator>
      <pubDate>Wed, 01 Apr 2026 10:35:16 +0000</pubDate>
      <link>https://forem.com/perldean/manage-the-health-of-your-cli-tools-at-scale-5glk</link>
      <guid>https://forem.com/perldean/manage-the-health-of-your-cli-tools-at-scale-5glk</guid>
      <description>&lt;p&gt;Your services have dashboards, tracing, and alerting. Your CLI tools print to STDOUT and exit. When something breaks, debugging starts at the API gateway -- everything upstream is a black box. This makes no sense.&lt;/p&gt;

&lt;p&gt;If your CLI talks to an API, it's part of the request path. Instrument it like any other participant.&lt;/p&gt;

&lt;p&gt;This post describes how we instrumented an internal Perl CLI -- the same &lt;code&gt;mycli&lt;/code&gt; tool from our &lt;a href="https://dev.to/perldean/shipping-a-perl-cli-as-a-single-file-with-appfatpacker-586d"&gt;earlier post on fatpacking&lt;/a&gt; -- with syslog logging, StatsD metrics, and correlation IDs. The post is strongly biased towards tooling internal to an organisation, which has the luxury of being opinionated: you control the deployment targets, you know where syslog goes, and you can lean on solved infrastructure rather than building your own. The principles generalise to any language and any CLI that talks to an API.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why observability matters in CLI tools
&lt;/h2&gt;

&lt;p&gt;Web services get dashboards as a matter of course[1]. Error rates, latency percentiles, request counts -- these are table stakes for any production service. CLI tools rarely get the same treatment, even when they're used just as heavily.&lt;/p&gt;

&lt;p&gt;Once your CLI emits metrics, you can build per-tool dashboards that show error rates broken down by command, by user cohort, by API version, by CLI version, by deployment target. This is the same dimensional analysis you'd do for a web service, applied to a tool that runs on someone's laptop.&lt;/p&gt;

&lt;p&gt;This integrates naturally with operational practices you're probably already using:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Continuous deployment.&lt;/strong&gt; When you ship a new CLI version, the dashboard shows whether error rates changed. If &lt;code&gt;command.device_list.errors&lt;/code&gt; spikes after a release, you know immediately -- not when someone files a ticket three days later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rollback decisions.&lt;/strong&gt; If error rates climb after a release, the dashboard tells you in minutes -- roll back now, debug later. Without metrics, you're guessing whether the new version is the cause or a coincidence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Canary deployments.&lt;/strong&gt; Roll the new version to 10% of jumpboxes. Compare &lt;code&gt;http.timing&lt;/code&gt; and &lt;code&gt;http.errors&lt;/code&gt; between the canary and the stable cohort. The same deployment strategy that works for services works for CLI tools, but only if you have the metrics to compare.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Feature flags.&lt;/strong&gt; If a new feature is gated behind a flag, metrics tell you whether the flagged code path is slower, more error-prone, or unused. Without instrumentation, feature flag decisions are based on "nobody complained".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Incident management.&lt;/strong&gt; During a site event, the CLI dashboard shows whether the tool is contributing to or affected by the problem. A spike in &lt;code&gt;http.status.503&lt;/code&gt; from the CLI tells the incident commander that the API is rejecting requests before users report it. Conversely, if the CLI error rate is flat during an incident, you can rule it out as a contributing factor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adoption and deprecation.&lt;/strong&gt; Metrics answer "is anyone still using the v1 endpoint?" and "has the team migrated to the new auth flow?" without surveys or guesswork.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The point is not that CLI tools are special -- it's that they're &lt;em&gt;not&lt;/em&gt;. They're participants in the same distributed system as your services, and they deserve the same observability treatment. The investment is small: a correlation ID, a handful of counters, and a logging lifecycle. The return is that your CLI becomes a first-class citizen in your operational tooling rather than a blind spot.&lt;/p&gt;

&lt;p&gt;[1] Yours does, right?&lt;/p&gt;

&lt;h2&gt;
  
  
  The three layers
&lt;/h2&gt;

&lt;p&gt;We instrument at three levels, each serving a different audience and persistence model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; Layer           Audience              Persistence
 -----           --------              -----------
 Verbose mode    Developer at terminal Ephemeral (STDERR)
 Syslog          Ops / incident review Durable (centralised logs)
 StatsD          Dashboards / alerting Aggregated (time-series)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A developer debugging their own command uses &lt;code&gt;--verbose&lt;/code&gt;. An on-call engineer investigating a reported issue searches syslog by invocation ID. A platform team monitors command usage and error rates on dashboards. Same underlying data, different consumers, different retention.&lt;/p&gt;

&lt;p&gt;Each layer is controlled independently and opt-in:&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="c"&gt;# Syslog only&lt;/span&gt;
&lt;span class="nv"&gt;MYCLI_LOG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 mycli device list

&lt;span class="c"&gt;# Verbose only (no syslog, no metrics)&lt;/span&gt;
mycli device list &lt;span class="nt"&gt;--verbose&lt;/span&gt;

&lt;span class="c"&gt;# Everything&lt;/span&gt;
&lt;span class="nv"&gt;MYCLI_LOG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 mycli device list &lt;span class="nt"&gt;--verbose&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;StatsD metrics emit whenever a &lt;code&gt;statsd_host&lt;/code&gt; is configured -- no-ops otherwise. Syslog requires &lt;code&gt;MYCLI_LOG=1&lt;/code&gt; -- deliberately opt-in, since CLI tools run on personal machines and writing to syslog on every invocation without consent would be surprising.&lt;/p&gt;

&lt;p&gt;The verbose layer itself has depth. &lt;code&gt;--verbose&lt;/code&gt; shows the &lt;em&gt;shape&lt;/em&gt; of the HTTP conversation -- method, URL, status, timing -- but deliberately omits headers and bodies to keep the output scannable. When that isn't enough, plugging in &lt;a href="https://metacpan.org/pod/LWP::ConsoleLogger::Everywhere" rel="noopener noreferrer"&gt;LWP::ConsoleLogger::Everywhere&lt;/a&gt; via &lt;code&gt;perl -M&lt;/code&gt; gives a full HTTP trace without the CLI needing to build one. More on this in the debugging spectrum section below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Invocation ID: the correlation key
&lt;/h2&gt;

&lt;p&gt;Every &lt;code&gt;mycli&lt;/code&gt; invocation generates a random 8-character hex ID at startup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight perl"&gt;&lt;code&gt;&lt;span class="k"&gt;my&lt;/span&gt; &lt;span class="nv"&gt;@chars&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;('&lt;/span&gt;&lt;span class="s1"&gt;0&lt;/span&gt;&lt;span class="p"&gt;'&lt;/span&gt; &lt;span class="o"&gt;..&lt;/span&gt; &lt;span class="p"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;9&lt;/span&gt;&lt;span class="p"&gt;',&lt;/span&gt; &lt;span class="p"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;a&lt;/span&gt;&lt;span class="p"&gt;'&lt;/span&gt; &lt;span class="o"&gt;..&lt;/span&gt; &lt;span class="p"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;f&lt;/span&gt;&lt;span class="p"&gt;');&lt;/span&gt;
&lt;span class="k"&gt;my&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;join&lt;/span&gt; &lt;span class="p"&gt;'',&lt;/span&gt; &lt;span class="nb"&gt;map&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;$chars&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;rand&lt;/span&gt; &lt;span class="nv"&gt;@chars&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;..&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This ID appears in three places:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Every syslog message&lt;/strong&gt; -- prefixed as &lt;code&gt;[f7a3b1c2]&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every HTTP request&lt;/strong&gt; -- sent as the &lt;code&gt;X-Invocation-Id&lt;/code&gt; header&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verbose STDERR output&lt;/strong&gt; -- printed at startup&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The server-side API logs this header alongside its own request ID. To trace a failing command end-to-end:&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="c"&gt;# Find the CLI side&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'f7a3b1c2'&lt;/span&gt; /var/log/mycli.log

&lt;span class="c"&gt;# Find the server side&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'f7a3b1c2'&lt;/span&gt; /var/log/api.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One string, full picture. No timestamps to correlate, no guessing which request came from which terminal.&lt;/p&gt;

&lt;h3&gt;
  
  
  User-Agent
&lt;/h3&gt;

&lt;p&gt;In addition to the invocation ID, set the &lt;code&gt;User-Agent&lt;/code&gt; header to &lt;code&gt;mycli/&amp;lt;version&amp;gt;&lt;/code&gt;. This is trivial and gives the server side a way to filter by CLI version without any custom header support -- useful for canary deployment analysis and for spotting users running outdated versions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two-way correlation
&lt;/h3&gt;

&lt;p&gt;The API returns its own request ID in a response header (&lt;code&gt;X-Request-Id&lt;/code&gt;). The CLI logs this too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;[f7a3b1c2] http: 200 OK (142ms, application/json, 8431 bytes) req=a1b2c3d4
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives you a join key in both directions: from the CLI's invocation ID you can find the server's request ID, and vice versa. When a user reports "mycli gave me an error", the request ID in the error message leads straight to the server-side trace.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the server needs to do
&lt;/h3&gt;

&lt;p&gt;The correlation only works if the server participates. The requirements are minimal:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Log the &lt;code&gt;X-Invocation-Id&lt;/code&gt; header&lt;/strong&gt; from incoming requests. Most API frameworks can do this with a single middleware or access log configuration change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Return a request ID&lt;/strong&gt; in every response (e.g., &lt;code&gt;X-Request-Id&lt;/code&gt;). Many frameworks generate this by default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Propagate both IDs&lt;/strong&gt; into the server's own tracing and logging. If the API uses structured logging or distributed tracing, attach the invocation ID as a field or span attribute so it appears in the same search results.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If the server doesn't log the invocation ID, the CLI-side correlation still works (you can grep your CLI logs by invocation ID), but you lose the end-to-end join. If the server doesn't return a request ID, the CLI can still log its own invocation ID, but the user can't hand a request ID to the API team and say "look this up".&lt;/p&gt;

&lt;p&gt;The ideal state is both: the CLI sends its ID, the server sends its ID, and both sides log both. This is a two-line change on the server and it makes every future debugging session faster.&lt;/p&gt;

&lt;p&gt;&lt;a id="structured-syslog"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Structured syslog
&lt;/h2&gt;

&lt;p&gt;Every invocation logs a structured lifecycle to syslog:&lt;/p&gt;

&lt;h3&gt;
  
  
  Startup
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;[f7a3b1c2] startup: cli: mycli device list --status Active
[f7a3b1c2] startup: perl: 5.36.0 on linux
[f7a3b1c2] startup: env: API_KEY=ab12****, SERVER_URL=https://api.internal
[f7a3b1c2] config: key source: file (~/.config/mycli/api-key)
[f7a3b1c2] config: format: table, fields: all, tty
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The API key is masked -- first four characters, then &lt;code&gt;****&lt;/code&gt;. Enough to identify which key is in use without leaking it to logs.&lt;/p&gt;

&lt;h3&gt;
  
  
  HTTP requests
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;[f7a3b1c2] http: GET https://api.internal/v1/devices
[f7a3b1c2] http: 200 OK (142ms, application/json, 8431 bytes) req=a1b2c3d4
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every request/response pair is logged with method, URL, status, elapsed time, content type, response size, and the server's request ID.&lt;/p&gt;

&lt;h3&gt;
  
  
  Shutdown
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;[f7a3b1c2] device_list: done (387ms, 24 results, 2 requests, cache 3/1)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One line summarising the entire command: wall-clock time, result count, number of HTTP requests made, and resolve cache statistics (3 items cached across 1 resource type).&lt;/p&gt;

&lt;h3&gt;
  
  
  Always format, conditionally emit
&lt;/h3&gt;

&lt;p&gt;A subtle design choice: the logger always formats every message, even when logging is disabled. Only the &lt;code&gt;syslog()&lt;/code&gt; call is conditional:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight perl"&gt;&lt;code&gt;&lt;span class="k"&gt;sub &lt;/span&gt;&lt;span class="nf"&gt;_emit&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;my&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$priority&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$detail&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;@_&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;my&lt;/span&gt; &lt;span class="nv"&gt;$msg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;sprintf&lt;/span&gt; &lt;span class="p"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[%s] %s: %s&lt;/span&gt;&lt;span class="p"&gt;',&lt;/span&gt; &lt;span class="nv"&gt;$self&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;_id&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nv"&gt;$context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$detail&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;syslog&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$priority&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;%s&lt;/span&gt;&lt;span class="p"&gt;',&lt;/span&gt; &lt;span class="nv"&gt;$msg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nv"&gt;$self&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;_enabled&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$msg&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This means formatting bugs surface during normal development, not only when someone enables logging in production. The cost is negligible -- &lt;code&gt;sprintf&lt;/code&gt; is fast.&lt;/p&gt;

&lt;p&gt;A note on philosophy: when syslog is enabled, all levels are transmitted -- info, debug, error. There is no runtime knob to suppress debug messages. The belief behind this is that logging should always be on in production, not enabled after a problem is suspected. The time you most need debug-level detail is exactly the time you can't reproduce the issue. You can never have too much log detail, with the obvious exception of user or employee personal data, which should never be logged at any level.&lt;/p&gt;

&lt;h3&gt;
  
  
  What not to log
&lt;/h3&gt;

&lt;p&gt;The API key masking (&lt;code&gt;ab12****&lt;/code&gt;) is one example of a broader principle: log enough to identify, not enough to exploit.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Credentials and secrets&lt;/strong&gt; -- mask API keys, tokens, and passwords. Show enough characters to distinguish between keys (we show four), then mask the rest. Apply the same caution to environment variables and URL query parameters that may carry tokens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Request and response bodies&lt;/strong&gt; -- don't log them. They may contain customer data, PII, or sensitive business logic. Log metadata (status, timing, size) but never content. Body inspection is what &lt;code&gt;LWP::ConsoleLogger&lt;/code&gt; is for -- interactive, ephemeral, on-demand.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a id="statsd-metrics"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  StatsD metrics
&lt;/h2&gt;

&lt;p&gt;Every command emits a standard set of metrics to StatsD:&lt;/p&gt;

&lt;h3&gt;
  
  
  Per-command metrics
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;mycli.command.&amp;lt;cmd&amp;gt;.calls&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;counter&lt;/td&gt;
&lt;td&gt;Command invocations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;mycli.command.&amp;lt;cmd&amp;gt;.timing&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;timing&lt;/td&gt;
&lt;td&gt;Wall-clock duration (ms)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;mycli.command.&amp;lt;cmd&amp;gt;.results&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;gauge&lt;/td&gt;
&lt;td&gt;Items returned&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;mycli.command.&amp;lt;cmd&amp;gt;.errors&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;counter&lt;/td&gt;
&lt;td&gt;Unhandled exceptions&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The command name is derived from the class hierarchy: &lt;code&gt;MyCLI::App::Command::device::list&lt;/code&gt; becomes &lt;code&gt;device_list&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Per-HTTP metrics
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;mycli.http.calls&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;counter&lt;/td&gt;
&lt;td&gt;Total HTTP requests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;mycli.http.timing&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;timing&lt;/td&gt;
&lt;td&gt;Per-request duration (ms)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;mycli.http.errors&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;counter&lt;/td&gt;
&lt;td&gt;Non-2xx responses&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;mycli.http.status.&amp;lt;code&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;counter&lt;/td&gt;
&lt;td&gt;Per-status-code breakdown&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Operational metrics
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;mycli.auth.key_source.&amp;lt;src&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;counter&lt;/td&gt;
&lt;td&gt;Where the API key came from&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;mycli.auth.url_source.&amp;lt;src&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;counter&lt;/td&gt;
&lt;td&gt;Where the server URL came from&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;mycli.config.file.found&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;counter&lt;/td&gt;
&lt;td&gt;Config file was loaded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;mycli.config.file.none&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;counter&lt;/td&gt;
&lt;td&gt;No config file found&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;mycli.output.format.&amp;lt;name&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;counter&lt;/td&gt;
&lt;td&gt;Output format selection&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  What this tells you
&lt;/h3&gt;

&lt;p&gt;The metrics answer questions that logs can't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;What commands are people actually using?&lt;/strong&gt; -- sort &lt;code&gt;command.*.calls&lt;/code&gt; by count. If nobody uses &lt;code&gt;crossconnect list&lt;/code&gt;, don't spend time improving it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is the API getting slower?&lt;/strong&gt; -- &lt;code&gt;http.timing&lt;/code&gt; percentiles over time. The CLI is seeing the same latency as your users, including TLS negotiation and DNS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Are auth errors increasing?&lt;/strong&gt; -- &lt;code&gt;http.status.401&lt;/code&gt; spike means keys are being rotated or revoked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How are people authenticating?&lt;/strong&gt; -- &lt;code&gt;auth.key_source.env&lt;/code&gt; vs &lt;code&gt;auth.key_source.file&lt;/code&gt; tells you whether your team has adopted the recommended credential flow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What output formats matter?&lt;/strong&gt; -- if 90% of usage is &lt;code&gt;output.format.json&lt;/code&gt;, your table renderer is mostly aesthetic.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Metric naming conventions
&lt;/h3&gt;

&lt;p&gt;Prefix every metric with the tool name (&lt;code&gt;mycli.*&lt;/code&gt;) to avoid collisions in a shared StatsD instance. Use a consistent dot-separated hierarchy (&lt;code&gt;mycli.command.&amp;lt;cmd&amp;gt;.calls&lt;/code&gt;) rather than flat names -- this makes metrics discoverable by browsing the tree. Watch cardinality: derive command names from a fixed set (like the class hierarchy) rather than user input, and keep dynamic segments like &lt;code&gt;http.status.&amp;lt;code&amp;gt;&lt;/code&gt; to naturally bounded sets.&lt;/p&gt;

&lt;p&gt;&lt;a id="verbose-mode-and-the-debugging-spectrum"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Verbose mode and the debugging spectrum
&lt;/h2&gt;

&lt;p&gt;The three layers above cover &lt;em&gt;durable&lt;/em&gt; observability -- data that outlives the terminal session. But the most common debugging scenario is someone at a keyboard wondering why their command isn't working. For this, the CLI has three levels of HTTP visibility:&lt;/p&gt;

&lt;h3&gt;
  
  
  Level 1: Silent (default)
&lt;/h3&gt;

&lt;p&gt;No HTTP output. The user sees formatted results only. Syslog and metrics still capture everything in the background.&lt;/p&gt;

&lt;h3&gt;
  
  
  Level 2: &lt;code&gt;--verbose&lt;/code&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;--&amp;gt; GET https://api.internal/v1/devices?status=Active
&amp;lt;-- 200 OK (142ms, application/json, 8431 bytes)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Printed to STDERR so it doesn't interfere with STDOUT piping. Shows method, URL, status, timing, and size. This is enough for "is my request hitting the right endpoint?" and "why is this slow?".&lt;/p&gt;

&lt;p&gt;The design choice here is restraint. Verbose mode shows the &lt;em&gt;shape&lt;/em&gt; of the conversation -- what was asked, what came back, how long it took. It deliberately omits headers and bodies. This keeps the output scannable when a command makes multiple requests.&lt;/p&gt;

&lt;h3&gt;
  
  
  Level 3: &lt;code&gt;LWP::ConsoleLogger::Everywhere&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;When &lt;code&gt;--verbose&lt;/code&gt; isn't enough -- when you need to see request headers, response headers, and full bodies -- plug in &lt;a href="https://metacpan.org/pod/LWP::ConsoleLogger::Everywhere" rel="noopener noreferrer"&gt;LWP::ConsoleLogger::Everywhere&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="c"&gt;# From source&lt;/span&gt;
perl &lt;span class="nt"&gt;-MLWP&lt;/span&gt;::ConsoleLogger::Everywhere &lt;span class="nt"&gt;-Ilib&lt;/span&gt; bin/mycli device get 42

&lt;span class="c"&gt;# Fatpacked binary (with API key redaction)&lt;/span&gt;
&lt;span class="nv"&gt;LWPCL_REDACT_HEADERS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Authorization &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;PERL5OPT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"-MLWP::ConsoleLogger::Everywhere"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  ./mycli-packed device get 42
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a full HTTP trace: every header, every byte of the request and response body, formatted and syntax-highlighted. It's invaluable for debugging serialisation issues, unexpected headers, or auth failures.&lt;/p&gt;

&lt;p&gt;The reason we don't build this into &lt;code&gt;--verbose&lt;/code&gt; is that it's a different tool for a different job. Verbose mode is for operators; full HTTP tracing is for developers debugging the CLI itself. The &lt;code&gt;-M&lt;/code&gt; flag means the capability is always available without cluttering the option namespace or adding a dependency that most users will never need.&lt;/p&gt;

&lt;h2&gt;
  
  
  Error reporting and surfacing correlation IDs
&lt;/h2&gt;

&lt;p&gt;When the API returns an error, the CLI needs to show the user enough information to report the problem without overwhelming them with internals. Our error output includes the server's request ID:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: 403 Forbidden
  The API key does not have permission to access this resource.
  Request ID: a1b2c3d4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The request ID is the bridge between the user and the operations team. "It gave me a 403, request ID a1b2c3d4" is a complete bug report. The on-call engineer greps the server logs for &lt;code&gt;a1b2c3d4&lt;/code&gt;, finds the full request context (authenticated user, requested resource, policy that denied access), and resolves the issue -- without asking the user to reproduce it, enable verbose mode, or paste terminal output.&lt;/p&gt;

&lt;p&gt;The invocation ID doesn't appear in normal error output -- it's an internal correlation key for log analysis, not a user-facing artifact. If syslog is enabled, the invocation ID is already in the logs alongside the request ID, providing the join in both directions.&lt;/p&gt;

&lt;p&gt;&lt;a id="the-execution-wrapper"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The execution wrapper
&lt;/h2&gt;

&lt;p&gt;All of this comes together in the base command's &lt;code&gt;execute()&lt;/code&gt; method, which wraps every leaf command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight perl"&gt;&lt;code&gt;&lt;span class="k"&gt;sub &lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;my&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$opt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;@_&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;my&lt;/span&gt; &lt;span class="nv"&gt;$cmd&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$self&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt;_metric_name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;my&lt;/span&gt; &lt;span class="nv"&gt;$start&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Time::HiRes::&lt;/span&gt;&lt;span class="nv"&gt;time&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nv"&gt;$self&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt;logger&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;start&lt;/span&gt;&lt;span class="p"&gt;');&lt;/span&gt;
    &lt;span class="nv"&gt;$self&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt;metrics&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt;increment&lt;/span&gt;&lt;span class="p"&gt;("&lt;/span&gt;&lt;span class="s2"&gt;command.&lt;/span&gt;&lt;span class="si"&gt;$cmd&lt;/span&gt;&lt;span class="s2"&gt;.calls&lt;/span&gt;&lt;span class="p"&gt;");&lt;/span&gt;

    &lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;$self&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt;_execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$opt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

    &lt;span class="k"&gt;my&lt;/span&gt; &lt;span class="nv"&gt;$elapsed_ms&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nn"&gt;Time::HiRes::&lt;/span&gt;&lt;span class="nv"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;$start&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nv"&gt;$MS_PER_SEC&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;my&lt;/span&gt; &lt;span class="nv"&gt;$requests&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$self&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt;client&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt;request_count&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;my&lt;/span&gt; &lt;span class="nv"&gt;$result_count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$self&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;_result_count&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;

    &lt;span class="nv"&gt;$self&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt;metrics&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt;timing&lt;/span&gt;&lt;span class="p"&gt;("&lt;/span&gt;&lt;span class="s2"&gt;command.&lt;/span&gt;&lt;span class="si"&gt;$cmd&lt;/span&gt;&lt;span class="s2"&gt;.timing&lt;/span&gt;&lt;span class="p"&gt;",&lt;/span&gt; &lt;span class="nv"&gt;$elapsed_ms&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$self&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt;metrics&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt;gauge&lt;/span&gt;&lt;span class="p"&gt;("&lt;/span&gt;&lt;span class="s2"&gt;command.&lt;/span&gt;&lt;span class="si"&gt;$cmd&lt;/span&gt;&lt;span class="s2"&gt;.results&lt;/span&gt;&lt;span class="p"&gt;",&lt;/span&gt; &lt;span class="nv"&gt;$result_count&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;defined&lt;/span&gt; &lt;span class="nv"&gt;$result_count&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;my&lt;/span&gt; &lt;span class="nv"&gt;$err&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="vg"&gt;$@&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$self&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt;metrics&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt;increment&lt;/span&gt;&lt;span class="p"&gt;("&lt;/span&gt;&lt;span class="s2"&gt;command.&lt;/span&gt;&lt;span class="si"&gt;$cmd&lt;/span&gt;&lt;span class="s2"&gt;.errors&lt;/span&gt;&lt;span class="p"&gt;");&lt;/span&gt;
        &lt;span class="nv"&gt;$self&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt;logger&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nb"&gt;die&lt;/span&gt; &lt;span class="nv"&gt;$err&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$self&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt;logger&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;sprintf&lt;/span&gt; &lt;span class="p"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;done (%dms, %s results, %d requests)&lt;/span&gt;&lt;span class="p"&gt;',&lt;/span&gt;
        &lt;span class="nv"&gt;$elapsed_ms&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$result_count&lt;/span&gt; &lt;span class="sr"&gt;//&lt;/span&gt; &lt;span class="p"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;n/a&lt;/span&gt;&lt;span class="p"&gt;',&lt;/span&gt; &lt;span class="nv"&gt;$requests&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Leaf commands implement &lt;code&gt;_execute()&lt;/code&gt; and don't think about observability at all. They call &lt;code&gt;$self-&amp;gt;client-&amp;gt;get(...)&lt;/code&gt;, render results, and return. The wrapper handles timing, logging, metrics, and error reporting. This is the single place where the observability contract is enforced -- no leaf command can accidentally skip it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design principles
&lt;/h2&gt;

&lt;p&gt;A few principles that guided these choices:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Zero cost when off.&lt;/strong&gt; Logging and metrics are lazy-initialised. If you never enable syslog or configure StatsD, the modules aren't even loaded.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Instrument the framework, not the features.&lt;/strong&gt; Leaf commands don't contain observability code. The base command wrapper and HTTP client handle everything. New commands get full instrumentation for free.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Correlate by default.&lt;/strong&gt; The invocation ID requires no opt-in. Every request carries it. The server just has to log it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Separate concerns by audience.&lt;/strong&gt; Verbose mode is for the person at the terminal. Syslog is for the person investigating after the fact. Metrics are for the person watching trends. Don't conflate them.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Don't build what you can plug in.&lt;/strong&gt; Full HTTP tracing via &lt;code&gt;LWP::ConsoleLogger&lt;/code&gt; is better than anything we'd build ourselves. Keep verbose mode lean and let the specialist tool handle the rest.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Testing observability
&lt;/h2&gt;

&lt;p&gt;Instrumentation code is easy to write and easy to break silently. If nobody notices that the invocation ID stopped appearing in syslog, it might be months before an incident reveals the gap. A few testing strategies:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Unit test the logger's formatting.&lt;/strong&gt; The &lt;code&gt;_emit&lt;/code&gt; method returns the formatted message even when syslog is disabled. Assert that the invocation ID, context, and detail appear in the expected format.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unit test metric emissions.&lt;/strong&gt; Mock the StatsD client and assert that &lt;code&gt;command.&amp;lt;cmd&amp;gt;.calls&lt;/code&gt; is incremented, &lt;code&gt;command.&amp;lt;cmd&amp;gt;.timing&lt;/code&gt; receives a value, and &lt;code&gt;command.&amp;lt;cmd&amp;gt;.errors&lt;/code&gt; fires on exception. These are contract tests -- they verify that the execution wrapper keeps its promises.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assert the invocation ID propagates.&lt;/strong&gt; Mock the HTTP client and verify that outgoing requests carry the &lt;code&gt;X-Invocation-Id&lt;/code&gt; header with the same value the logger is using.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Integration test the full lifecycle.&lt;/strong&gt; Run a command against a mocked API, capture STDERR with &lt;code&gt;--verbose&lt;/code&gt;, and assert the &lt;code&gt;--&amp;gt;&lt;/code&gt; / &lt;code&gt;&amp;lt;--&lt;/code&gt; lines appear with the expected method, URL, and status.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The "always format, conditionally emit" pattern helps here: the logger exercises all formatting code paths in every test run, even when syslog isn't available in the test environment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tracing an incident: a walkthrough
&lt;/h2&gt;

&lt;p&gt;Here's how the instrumentation plays out during a real debugging scenario. This walkthrough exercises every layer described above: error output with a request ID, the metrics dashboard, syslog correlation, and two-way ID join.&lt;/p&gt;

&lt;p&gt;A user reports: "mycli device list is failing intermittently." They include the error message:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: 503 Service Unavailable
  The API is temporarily unable to handle the request.
  Request ID: e4f5a6b7
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 1: Find the server side.&lt;/strong&gt; The on-call engineer greps the API logs for &lt;code&gt;e4f5a6b7&lt;/code&gt; and finds the request hit a backend that was in the middle of a deployment. The 503 was a transient error from a rolling restart.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2: Assess the blast radius.&lt;/strong&gt; But is it just this one user? The engineer checks the CLI dashboard: &lt;code&gt;mycli.http.status.503&lt;/code&gt; shows a spike over the last 20 minutes, coinciding with the deployment window. It's not one user -- it's everyone hitting that backend.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3: Find the CLI side.&lt;/strong&gt; The server log for &lt;code&gt;e4f5a6b7&lt;/code&gt; also contains the &lt;code&gt;X-Invocation-Id: c8d9e0f1&lt;/code&gt;. Grepping the centralised CLI logs for &lt;code&gt;c8d9e0f1&lt;/code&gt; shows the full client-side context: which command was run, which user ran it, what arguments were passed, and that the request took 12 seconds before returning 503 (suggesting the backend was hanging, not failing fast).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4: Verify the fix.&lt;/strong&gt; After the deployment completes, the 503 counter drops to zero. The engineer confirms on the dashboard that error rates are back to baseline across all commands.&lt;/p&gt;

&lt;p&gt;Total debugging time: minutes. Without instrumentation, this would have been a ticket saying "it's broken sometimes" followed by back-and-forth to reproduce, enable verbose mode, and collect output.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; +-------------------+    X-Invocation-Id    +-------------------+
 | mycli             |-----------------------| API               |
 |                   |    X-Request-Id       |                   |
 | - syslog [id]     |&amp;lt;----------------------| - access log [id] |
 | - StatsD metrics  |                       | - request trace   |
 | - verbose STDERR  |                       |                   |
 +-------------------+                       +-------------------+
         |                                           |
         v                                           v
 +-------------------+                       +-------------------+
 | Centralised logs  |&amp;lt;--- grep by ID ------&amp;gt;| Centralised logs  |
 | Metrics dashboard |                       | APM / tracing     |
 +-------------------+                       +-------------------+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key takeaways:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Your CLI is part of the distributed system.&lt;/strong&gt; If it talks to an API, it's a participant in the request path -- treat it like a service, not a script.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A correlation ID is the single most valuable thing you can add.&lt;/strong&gt; One random string, sent as an HTTP header, ties client logs to server logs. Everything else builds on this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separate layers by audience.&lt;/strong&gt; Verbose mode for the developer at the terminal, structured logs for the on-call engineer after the fact, metrics for dashboards and alerting. Same data, different consumers, different lifetimes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Instrument the framework, not the features.&lt;/strong&gt; A single execution wrapper gives every command logging, metrics, and error reporting for free. Leaf commands shouldn't contain observability code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The server needs to participate.&lt;/strong&gt; Log the client's invocation ID, return your own request ID. Without this, correlation is one-sided.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log everything except secrets and personal data.&lt;/strong&gt; Mask credentials, never log request bodies, and keep logging always on -- the time you need debug detail is the time you can't reproduce the issue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Start simple, keep the door open.&lt;/strong&gt; Wrap your logging backend so the rest of the codebase never touches it directly. Start with whatever works for your deployment targets today -- Sys::Syslog, Fluent::Logger, a file. When your infrastructure is ready for OpenTelemetry or wide events (see Appendix A), the swap is localised.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The investment is small: a correlation ID, a handful of counters, and a logging lifecycle. The return is that your CLI becomes a first-class citizen in your operational tooling rather than a blind spot.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://metacpan.org/pod/LWP::ConsoleLogger::Everywhere" rel="noopener noreferrer"&gt;LWP::ConsoleLogger::Everywhere&lt;/a&gt; -- drop-in full HTTP tracing for any LWP-based client&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://no-color.org/" rel="noopener noreferrer"&gt;NO_COLOR&lt;/a&gt; -- convention for suppressing colour output, relevant to verbose/debug output formatting&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://opentelemetry.io/" rel="noopener noreferrer"&gt;OpenTelemetry&lt;/a&gt; -- industry-standard observability framework; &lt;a href="https://metacpan.org/pod/OpenTelemetry" rel="noopener noreferrer"&gt;Perl SDK on CPAN&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/statsd/statsd" rel="noopener noreferrer"&gt;StatsD&lt;/a&gt; -- the metrics aggregation protocol used for CLI instrumentation&lt;/li&gt;
&lt;li&gt;
&lt;a href="///fatpack-blog-post.html"&gt;Shipping a Perl CLI as a single file with App::FatPacker&lt;/a&gt; -- companion post on building and distributing &lt;code&gt;mycli&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Getting started
&lt;/h2&gt;

&lt;p&gt;If you want to add observability to an existing CLI tool, here's a practical order of operations. Each step is independently useful -- you don't need to do all five before any of them pay off.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Generate a random invocation ID at startup.&lt;/strong&gt; Eight hex characters is enough. Send it as an &lt;code&gt;X-Invocation-Id&lt;/code&gt; header on every HTTP request. This single change makes every future debugging session easier.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set &lt;code&gt;User-Agent&lt;/code&gt; to &lt;code&gt;&amp;lt;tool&amp;gt;/&amp;lt;version&amp;gt;&lt;/code&gt;.&lt;/strong&gt; Trivial, and it lets the server side filter by CLI version without any custom header support.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log three lifecycle events.&lt;/strong&gt; Startup (command line, environment, config source), each HTTP request/response (method, URL, status, timing), and shutdown (duration, result count). Even logging to STDERR behind a &lt;code&gt;--debug&lt;/code&gt; flag is better than nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Emit one counter per command invocation.&lt;/strong&gt; If you have StatsD or a metrics collector, &lt;code&gt;mycli.command.&amp;lt;cmd&amp;gt;.calls&lt;/code&gt; is the single most useful metric -- it tells you what people are actually using. If you don't have a metrics pipeline, a cheap alternative is to emit &lt;code&gt;key=value&lt;/code&gt; pairs in your log lines (e.g. &lt;code&gt;command=device_list duration_ms=387 status=ok&lt;/code&gt;) -- most log aggregation tools, including &lt;a href="https://grafana.com/docs/grafana/latest/visualizations/panels-visualizations/query-transform-data/transform-data/#extract-fields" rel="noopener noreferrer"&gt;Grafana itself&lt;/a&gt;, can extract fields from these lines and build charts and dashboards without a separate metrics stack.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wrap your command entry point.&lt;/strong&gt; Move timing, logging, and metric emission into a single wrapper around leaf command execution. New commands get instrumentation for free, and no leaf command can accidentally skip it.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;a id="appendix-a-wide-events"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Appendix A: Wide events
&lt;/h2&gt;

&lt;p&gt;Our implementation uses separate syslog lines for each lifecycle phase (startup, HTTP, shutdown) and separate StatsD counters for aggregation. This works, but it means correlating data across multiple log lines at query time -- you need the invocation ID to join them together.&lt;/p&gt;

&lt;p&gt;An increasingly popular alternative is the &lt;strong&gt;wide event&lt;/strong&gt; (or what Stripe called a &lt;a href="https://stripe.com/blog/canonical-log-lines" rel="noopener noreferrer"&gt;canonical log line&lt;/a&gt; in 2019): a single, information-dense structured record emitted once per unit of work, containing every attribute you collected along the way. Instead of five syslog lines and ten StatsD counters, you emit one event with fields like &lt;code&gt;command=device_list duration_ms=387 results=24 http_requests=2 http_status=200 auth_source=file output_format=table cache_hits=3&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The advantages are significant:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Faster queries&lt;/strong&gt; -- all the data is colocated in one record. No joins, no correlation by ID.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ad hoc analysis&lt;/strong&gt; -- during an incident you can group by any combination of fields without having pre-defined a metric for it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simpler pipeline&lt;/strong&gt; -- one event replaces multiple log lines and multiple metric emissions. Less code, fewer failure modes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We didn't take this approach because our logging infrastructure is syslog-based and doesn't support high-cardinality structured queries. If you have access to a columnar store (Honeycomb, ClickHouse, a data warehouse), wide events are the stronger choice. The execution wrapper already collects all the data in one place -- the change would be emitting it as a single structured record instead of spreading it across syslog and StatsD.&lt;/p&gt;

&lt;p&gt;For more on wide events, see &lt;a href="https://jeremymorrell.dev/blog/a-practitioners-guide-to-wide-events/" rel="noopener noreferrer"&gt;A Practitioner's Guide to Wide Events&lt;/a&gt; and &lt;a href="https://isburmistrov.substack.com/p/all-you-need-is-wide-events-not-metrics" rel="noopener noreferrer"&gt;All You Need Is Wide Events, Not Metrics&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Appendix B: Why Sys::Syslog and not a logging framework?
&lt;/h2&gt;

&lt;p&gt;Perl has several mature logging frameworks -- &lt;a href="https://metacpan.org/pod/Log::Any" rel="noopener noreferrer"&gt;Log::Any&lt;/a&gt;, &lt;a href="https://metacpan.org/pod/Log::Dispatch" rel="noopener noreferrer"&gt;Log::Dispatch&lt;/a&gt;, &lt;a href="https://metacpan.org/pod/Log::Log4perl" rel="noopener noreferrer"&gt;Log::Log4perl&lt;/a&gt; -- any of which would be a fine choice here. We went with &lt;a href="https://metacpan.org/pod/Sys::Syslog" rel="noopener noreferrer"&gt;Sys::Syslog&lt;/a&gt; directly. This is an opinionated trade-off worth explaining.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Sys::Syslog gives you
&lt;/h3&gt;

&lt;p&gt;Syslog is a solved problem on servers and jumpboxes. The local syslog daemon (rsyslog, syslog-ng, journald) handles buffering, rotation, compression, and forwarding to a central log aggregator. The CLI doesn't need to know where the logs go, how to authenticate to a remote endpoint, or what to do when the network is down. It calls &lt;code&gt;syslog()&lt;/code&gt;, the daemon takes it from there. This is a clean separation of concerns: the application produces structured messages, the infrastructure handles transmission.&lt;/p&gt;

&lt;p&gt;There are no extra dependencies beyond core Perl. No configuration files, no adapter registration, no output plugin selection. The logger module is ~50 lines. For a fatpacked binary where every dependency has a cost, this matters.&lt;/p&gt;

&lt;h3&gt;
  
  
  What a framework would give you
&lt;/h3&gt;

&lt;p&gt;A framework like Log::Any or Log::Dispatch provides output abstraction: you write &lt;code&gt;$log-&amp;gt;info(...)&lt;/code&gt; and configure the destination at deployment time -- syslog, a file, STDERR, a network endpoint, or multiple at once. The application code doesn't change when the destination does. This is a genuine advantage when the tool runs in environments with different logging infrastructure, or when libraries you depend on already use Log::Any.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where the trade-off bites
&lt;/h3&gt;

&lt;p&gt;The opinionated choice of Sys::Syslog works well when every target machine runs a syslog daemon. It falls apart on developer laptops and desktops.&lt;/p&gt;

&lt;p&gt;macOS ships with a syslog-compatible interface via Apple System Log, but the log viewer has moved to &lt;code&gt;Console.app&lt;/code&gt; and the unified logging system. Messages from &lt;code&gt;syslog()&lt;/code&gt; end up in a different place than most macOS users expect, and the retention policy may discard them quickly. On Windows, there is no syslog daemon at all.&lt;/p&gt;

&lt;p&gt;You have two choices here:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Accept the gap.&lt;/strong&gt; Detect the platform at startup and disable syslog on macOS and Windows. The CLI still has &lt;code&gt;--verbose&lt;/code&gt; for interactive debugging, and StatsD metrics still flow if a collector is configured. You lose durable logging on developer machines, but you avoid adding complexity to the CLI itself. This is the approach we took -- the primary deployment targets are Linux servers and jumpboxes where syslog is reliable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Solve logging everywhere.&lt;/strong&gt; Use a framework like Log::Dispatch with pluggable outputs: syslog on Linux, a file on macOS, a network endpoint everywhere. This means the CLI now owns the full logging pipeline: transport selection, buffering when the destination is unavailable, possibly TLS for log data in transit, possibly client-side authentication to a log aggregator. Each of these is individually tractable, but collectively they add configuration surface, failure modes, and dependencies that the syslog approach avoids entirely.&lt;/p&gt;

&lt;p&gt;There is a middle ground: In an organization with tight control of staff laptops and desktops (as is increasingly common), solving the logging problems in the CLI or having a local logging daemon is very feasible.&lt;/p&gt;

&lt;h3&gt;
  
  
  Another opinionated choice: Fluent::Logger
&lt;/h3&gt;

&lt;p&gt;If your infrastructure runs Fluentd or Fluent Bit, &lt;a href="https://metacpan.org/pod/Fluent::Logger" rel="noopener noreferrer"&gt;Fluent::Logger&lt;/a&gt; is worth considering as an equally opinionated alternative to Sys::Syslog. It sends structured events directly to a Fluent collector over a local socket or TCP, which then handles routing, buffering, and delivery to whatever backend you use (Elasticsearch, S3, a data warehouse). Like Sys::Syslog, it delegates transport to purpose-built infrastructure. Unlike syslog, the events are natively structured -- key-value pairs rather than format strings -- which makes the path to wide events shorter.&lt;/p&gt;

&lt;p&gt;The advantage of making an opinionated backend choice -- whether that's Sys::Syslog, Fluent::Logger, or something else entirely -- is that it removes abstraction layers that aren't adding value. If you know where your logs go, a framework like Log::Any is indirection without a benefit. You pay for adapter registration, output plugin configuration, and an extra dependency, but you only ever use one backend. An abstraction earns its keep when requirements are genuinely uncertain; when they're known, it's just ceremony.&lt;/p&gt;

&lt;h3&gt;
  
  
  The elephant in the room: OpenTelemetry
&lt;/h3&gt;

&lt;p&gt;Of course, the industry is converging on &lt;a href="https://opentelemetry.io/" rel="noopener noreferrer"&gt;OpenTelemetry&lt;/a&gt; as the standard answer to all of the above. Perl has solid support via the &lt;a href="https://metacpan.org/pod/OpenTelemetry" rel="noopener noreferrer"&gt;OpenTelemetry&lt;/a&gt; distribution on CPAN. If your organisation already runs an OTel collector, plumbing it into your CLI from the start is the right long-term bet.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keeping the door open
&lt;/h3&gt;

&lt;p&gt;The important thing is that the rest of the codebase never touches Sys::Syslog directly. Every module calls &lt;code&gt;$self-&amp;gt;logger-&amp;gt;info(...)&lt;/code&gt;, &lt;code&gt;-&amp;gt;error(...)&lt;/code&gt;, or &lt;code&gt;-&amp;gt;debug(...)&lt;/code&gt;. The actual syslog calls are isolated to two private methods in the logger class: &lt;code&gt;_emit&lt;/code&gt; (which formats and transmits) and &lt;code&gt;_open_syslog&lt;/code&gt; (which calls &lt;code&gt;openlog&lt;/code&gt;). Swapping Sys::Syslog for Log::Dispatch, Fluent::Logger, or an OpenTelemetry log bridge would mean changing those two methods and nothing else.&lt;/p&gt;

&lt;p&gt;This is the pragmatic middle path: start with the simplest backend that works for your deployment targets, but wrap it so the choice is easy to revisit. For a server-side CLI deployed to a controlled fleet, Sys::Syslog is a sensible default -- zero-config, zero-dependency, and delegates the hard problems to purpose-built infrastructure. If the tool later needs to run on developer laptops as a primary deployment target, the logging framework swap is a localised change rather than a rewrite.&lt;/p&gt;




&lt;h2&gt;
  
  
  Discussion
&lt;/h2&gt;

&lt;p&gt;Have you plumbed observability into a CLI tool? I'd love to hear what worked and what didn't -- whether you went with OpenTelemetry traces, wide events from day one, or bolted logging on after the fact. What was the moment that made you invest in CLI instrumentation? Was it an incident that was hard to trace, a question about adoption you couldn't answer, or just good hygiene? And if you haven't done it yet -- what's holding you back?&lt;/p&gt;

</description>
      <category>sre</category>
      <category>perl</category>
      <category>monitoring</category>
      <category>cli</category>
    </item>
    <item>
      <title>Shipping a Perl CLI as a single file with App::FatPacker</title>
      <dc:creator>Dean Hamstead</dc:creator>
      <pubDate>Tue, 31 Mar 2026 12:41:49 +0000</pubDate>
      <link>https://forem.com/perldean/shipping-a-perl-cli-as-a-single-file-with-appfatpacker-586d</link>
      <guid>https://forem.com/perldean/shipping-a-perl-cli-as-a-single-file-with-appfatpacker-586d</guid>
      <description>&lt;p&gt;Modern software distribution has converged on a simple idea: ship a self-contained artifact. Whether that means a statically linked binary, a container image, or a snap/flatpak, the benefits are the same -- dependency management is solved at build time, platform differences are absorbed, and upgrades and rollbacks reduce to swapping a single file.&lt;/p&gt;

&lt;p&gt;Perl's &lt;a href="https://metacpan.org/pod/App::FatPacker" rel="noopener noreferrer"&gt;App::FatPacker&lt;/a&gt; applies the same principle to Perl scripts. It bundles every pure-Perl dependency into a single executable file. No &lt;code&gt;cpanm&lt;/code&gt;, no &lt;code&gt;local::lib&lt;/code&gt;, no Makefile on the target -- just copy the file and run it. The technique is well-established -- &lt;code&gt;cpm&lt;/code&gt; (the CPAN installer we use in the build) is itself distributed as a fatpacked binary.&lt;/p&gt;

&lt;p&gt;The distribution pipeline looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; Code repo --&amp;gt; CI --&amp;gt; fatpack --&amp;gt; deploy --&amp;gt; laptops / jumpboxes / servers
                       |
                 single file,
                 no dependencies
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This post walks through how we fatpacked an internal CLI we'll call &lt;code&gt;mycli&lt;/code&gt;, a ~90-module Perl app, into a single file. The approach generalises to any &lt;code&gt;App::Cmd&lt;/code&gt;-based tool.&lt;/p&gt;

&lt;p&gt;A good practice for internal tools is to provide all three interfaces: a web frontend, an API, and a CLI. The web frontend is the easiest to discover; the API enables automation and integration; the CLI is the fastest path for engineers who live in a terminal. FatPacker makes the CLI trivially deployable.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;mycli&lt;/code&gt; is a thin client -- it talks to an internal REST API over HTTPS and renders the response locally. There is no local state beyond a config file and environment variables. You could build an equivalent tool against a binary RPC protocol such as gRPC or Thrift -- the fatpacking approach is the same.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; +--------------------+           +-------------------+
 | Workstation        |   HTTPS   | Server            |
 |                    |           |                   |
 |  $ mycli resource  |----------&amp;gt;|  REST API ---+    |
 |    list ...        |&amp;lt;----------|  (JSON)  DB  |    |
 +--------------------+           +-------------------+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Despite being a thin client, &lt;code&gt;mycli&lt;/code&gt; is not trivial. It includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pluggable output renderers (table, JSON, YAML, CSV, plain text)&lt;/li&gt;
&lt;li&gt;Colour output with &lt;code&gt;NO_COLOR&lt;/code&gt; support&lt;/li&gt;
&lt;li&gt;Automatic pager integration (&lt;code&gt;less -RFX&lt;/code&gt;) and pipe/TTY detection&lt;/li&gt;
&lt;li&gt;Activity spinner&lt;/li&gt;
&lt;li&gt;Multi-format ID resolution (numeric, UUID prefix, name lookup)&lt;/li&gt;
&lt;li&gt;Command aliases (&lt;code&gt;ls&lt;/code&gt;/&lt;code&gt;list&lt;/code&gt;, &lt;code&gt;get&lt;/code&gt;/&lt;code&gt;show&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Config file discovery chain (env var, XDG path, dotfile)&lt;/li&gt;
&lt;li&gt;Timezone-aware timestamp rendering&lt;/li&gt;
&lt;li&gt;Structured syslog logging with per-invocation correlation IDs&lt;/li&gt;
&lt;li&gt;StatsD metrics instrumentation&lt;/li&gt;
&lt;li&gt;HTTP debugging hooks&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All of this fatpacks cleanly because each feature is backed by pure-Perl modules.&lt;/p&gt;

&lt;p&gt;This makes it an ideal fatpack candidate: the only XS dependency is &lt;code&gt;Net::SSLeay&lt;/code&gt; for TLS, which is typically already present on the target system. Everything else is pure Perl.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why FatPacker over PAR::Packer?
&lt;/h2&gt;

&lt;p&gt;The other well-known option for single-file Perl distribution is &lt;a href="https://metacpan.org/pod/PAR::Packer" rel="noopener noreferrer"&gt;PAR::Packer&lt;/a&gt;. PAR bundles everything -- including XS modules and even the &lt;code&gt;perl&lt;/code&gt; interpreter itself -- into a self-extracting archive. At runtime it unpacks to a temp directory and executes from there.&lt;/p&gt;

&lt;p&gt;FatPacker takes a different approach: modules are inlined as strings inside the script and served via a custom &lt;code&gt;@INC&lt;/code&gt; hook. There is no extraction step, no temp directory, and no architecture coupling. The trade-off is that FatPacker only handles pure Perl -- XS modules must already be on the target.&lt;/p&gt;

&lt;p&gt;For a thin REST client where the only XS dependency is &lt;code&gt;Net::SSLeay&lt;/code&gt;, FatPacker wins on simplicity: the output is a plain Perl script, it starts instantly, and it runs on any architecture with a compatible &lt;code&gt;perl&lt;/code&gt;. PAR is the better choice when you need to bundle XS-heavy dependencies or ship a binary to machines without Perl at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What fatpacking does
&lt;/h2&gt;

&lt;p&gt;FatPacker prepends a &lt;code&gt;BEGIN&lt;/code&gt; block to your script containing every dependency as a string literal, keyed by module path. A custom &lt;code&gt;@INC&lt;/code&gt; hook serves these strings to &lt;code&gt;require&lt;/code&gt; instead of reading from disk. The original script is appended unchanged.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; bin/mycli mycli-packed
&lt;span class="go"&gt;      13 bin/mycli
   48721 mycli-packed
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That ~49k line file runs identically to the original, on any machine with Perl 5.24+.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with naive fatpacking
&lt;/h2&gt;

&lt;p&gt;The standard FatPacker workflow is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;fatpack trace bin/mycli
fatpack packlists-for &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;fatpacker.trace&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; packlists
fatpack tree &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;packlists&lt;span class="si"&gt;)&lt;/span&gt;
fatpack file bin/mycli &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; mycli-packed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This breaks for non-trivial apps because &lt;code&gt;fatpack trace&lt;/code&gt; uses compile-time analysis (&lt;code&gt;B::minus_c&lt;/code&gt;). It misses anything loaded at runtime via &lt;code&gt;require&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;App::Cmd&lt;/code&gt; discovers commands via &lt;code&gt;Module::Pluggable&lt;/code&gt; at runtime&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Text::ANSITable&lt;/code&gt; loads border styles and colour themes dynamically&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;LWP::UserAgent&lt;/code&gt; loads protocol handlers on first request&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;YAML::Any&lt;/code&gt; probes for available backends at runtime&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the trace misses a module, the packed binary dies with &lt;code&gt;Can't locate Foo/Bar.pm in @INC&lt;/code&gt; at the worst possible moment.&lt;/p&gt;

&lt;h2&gt;
  
  
  The solution: a custom trace helper
&lt;/h2&gt;

&lt;p&gt;Instead of relying on &lt;code&gt;fatpack trace&lt;/code&gt;, we wrote a helper script that &lt;code&gt;require&lt;/code&gt;s every module the app could ever load, then dumps &lt;code&gt;%INC&lt;/code&gt; at exit. This captures the complete runtime dependency tree.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight perl"&gt;&lt;code&gt;&lt;span class="c1"&gt;#!/usr/bin/env perl&lt;/span&gt;
&lt;span class="c1"&gt;# bin/trace-helper -- not shipped, build-time only&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nv"&gt;strict&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nv"&gt;warnings&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nv"&gt;lib&lt;/span&gt; &lt;span class="p"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lib&lt;/span&gt;&lt;span class="p"&gt;';&lt;/span&gt;

&lt;span class="c1"&gt;# Modules loaded lazily that fatpack misses&lt;/span&gt;
&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="nn"&gt;Data::Unixish::&lt;/span&gt;&lt;span class="nv"&gt;Apply&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="nn"&gt;Digest::&lt;/span&gt;&lt;span class="nv"&gt;SHA&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="nn"&gt;HTTP::&lt;/span&gt;&lt;span class="nv"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="nn"&gt;LWP::&lt;/span&gt;&lt;span class="nv"&gt;UserAgent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="nn"&gt;String::&lt;/span&gt;&lt;span class="nv"&gt;RewritePrefix&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;# Exercise objects to trigger deep runtime loads&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="nn"&gt;Text::&lt;/span&gt;&lt;span class="nv"&gt;ANSITable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;my&lt;/span&gt; &lt;span class="nv"&gt;$t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Text::&lt;/span&gt;&lt;span class="nv"&gt;ANSITable&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;use_color&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;use_utf8&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$t&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt;border_style&lt;/span&gt;&lt;span class="p"&gt;('&lt;/span&gt;&lt;span class="s1"&gt;UTF8::SingleLineBold&lt;/span&gt;&lt;span class="p"&gt;');&lt;/span&gt;
    &lt;span class="nv"&gt;$t&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt;color_theme&lt;/span&gt;&lt;span class="p"&gt;('&lt;/span&gt;&lt;span class="s1"&gt;Text::ANSITable::Standard::NoGradation&lt;/span&gt;&lt;span class="p"&gt;');&lt;/span&gt;
    &lt;span class="nv"&gt;$t&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;(['&lt;/span&gt;&lt;span class="s1"&gt;a&lt;/span&gt;&lt;span class="p"&gt;']);&lt;/span&gt;
    &lt;span class="nv"&gt;$t&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt;add_row&lt;/span&gt;&lt;span class="p"&gt;(['&lt;/span&gt;&lt;span class="s1"&gt;1&lt;/span&gt;&lt;span class="p"&gt;']);&lt;/span&gt;
    &lt;span class="nv"&gt;$t&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt;draw&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;# forces all rendering deps to load&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Every App::Cmd leaf command&lt;/span&gt;
&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="nn"&gt;MyCLI::&lt;/span&gt;&lt;span class="nv"&gt;App&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="nn"&gt;MyCLI::App::Command::device::&lt;/span&gt;&lt;span class="nv"&gt;list&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="nn"&gt;MyCLI::App::Command::device::&lt;/span&gt;&lt;span class="nv"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;# ... all 80+ command modules ...&lt;/span&gt;

&lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;open&lt;/span&gt; &lt;span class="k"&gt;my&lt;/span&gt; &lt;span class="nv"&gt;$fh&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;',&lt;/span&gt; &lt;span class="p"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fatpacker.trace&lt;/span&gt;&lt;span class="p"&gt;'&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="nb"&gt;die&lt;/span&gt; &lt;span class="vg"&gt;$!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;my&lt;/span&gt; &lt;span class="nv"&gt;$inc&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nb"&gt;keys&lt;/span&gt; &lt;span class="nv"&gt;%INC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;next&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="nb"&gt;defined&lt;/span&gt; &lt;span class="nv"&gt;$INC&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$inc&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;
        &lt;span class="k"&gt;next&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nv"&gt;$inc&lt;/span&gt; &lt;span class="o"&gt;=~&lt;/span&gt; &lt;span class="sr"&gt;m{\AMyCLI/}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;# our own modules come from lib/&lt;/span&gt;
        &lt;span class="k"&gt;print&lt;/span&gt; &lt;span class="nv"&gt;$fh&lt;/span&gt; &lt;span class="p"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$inc&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="p"&gt;";&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key points:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Don't call &lt;code&gt;-&amp;gt;run&lt;/code&gt;&lt;/strong&gt; -- &lt;code&gt;App::Cmd&lt;/code&gt; subdispatch will die on duplicate
command names across namespaces. Just &lt;code&gt;require&lt;/code&gt; every leaf.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exercise both code paths&lt;/strong&gt; -- &lt;code&gt;Text::ANSITable&lt;/code&gt; loads different modules
for colour vs plain, UTF-8 vs ASCII. Instantiate both.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exclude your own namespace&lt;/strong&gt; -- FatPacker embeds modules from &lt;code&gt;fatlib/&lt;/code&gt;;
your &lt;code&gt;lib/&lt;/code&gt; modules are embedded separately. Including them in the trace
causes duplicates.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Forcing pure-Perl backends
&lt;/h2&gt;

&lt;p&gt;FatPacker can only bundle pure Perl. Many popular modules ship dual XS/pure-Perl backends and prefer XS at runtime. If XS is available during the trace, the pure-Perl fallback won't appear in &lt;code&gt;%INC&lt;/code&gt; and won't get bundled.&lt;/p&gt;

&lt;p&gt;Force pure-Perl mode during the build:&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="c"&gt;# In the fatpack build script&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;B_HOOKS_ENDOFSCOPE_IMPLEMENTATION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;PP
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;LIST_MOREUTILS_PP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;MOO_XS_DISABLE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PACKAGE_STASH_IMPLEMENTATION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;PP
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PARAMS_VALIDATE_IMPLEMENTATION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;PP
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PERL_JSON_BACKEND&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;JSON::PP
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PUREPERL_ONLY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;PUREPERL_ONLY=1&lt;/code&gt; is a &lt;a href="https://metacpan.org/pod/ExtUtils::MakeMaker#PUREPERL_ONLY" rel="noopener noreferrer"&gt;convention&lt;/a&gt; respected by many dual XS/PP distributions at install time, preventing XS compilation entirely. The per-module variables above cover modules that don't check &lt;code&gt;PUREPERL_ONLY&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Combine this with &lt;code&gt;--pp&lt;/code&gt; at install time to avoid pulling in XS at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cpm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-L&lt;/span&gt; &lt;span class="nb"&gt;local&lt;/span&gt; &lt;span class="nt"&gt;--target-perl&lt;/span&gt; 5.24.0 &lt;span class="nt"&gt;--pp&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Pinning the target Perl version
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;--target-perl&lt;/code&gt; flag to &lt;code&gt;cpm&lt;/code&gt; is critical and easy to overlook. Without it, &lt;code&gt;cpm&lt;/code&gt; resolves dependency versions against your build machine's Perl. If you're building on 5.38 but deploying to a jumpbox running 5.24, you'll silently install module versions that use &lt;code&gt;postfix dereferencing&lt;/code&gt;, &lt;code&gt;subroutine signatures&lt;/code&gt;, or other features that don't exist on the target.&lt;br&gt;
The packed binary will fail at runtime with a syntax error -- far from the build where you could catch it.&lt;/p&gt;

&lt;p&gt;This tells &lt;code&gt;cpm&lt;/code&gt;'s resolver to only consider module versions whose metadata declares compatibility with 5.24.0. Combined with &lt;code&gt;perl -c&lt;/code&gt; as a post-install sanity check, this catches version mismatches before the slow trace step.&lt;/p&gt;
&lt;h2&gt;
  
  
  The complete build script
&lt;/h2&gt;

&lt;p&gt;Here is the full pipeline, wrapped in a shell script. It supports incremental builds (reuses &lt;code&gt;local/&lt;/code&gt; and trace cache) and &lt;code&gt;--clean&lt;/code&gt; for full rebuilds.&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="c"&gt;#!/bin/sh&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt;

&lt;span class="nv"&gt;CLEAN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"--clean"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;CLEAN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1

&lt;span class="c"&gt;# 0. Prerequisites&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;cmd &lt;span class="k"&gt;in &lt;/span&gt;cpm fatpack perl&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
    &lt;/span&gt;&lt;span class="nb"&gt;command&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$cmd&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1 &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Error: '&lt;/span&gt;&lt;span class="nv"&gt;$cmd&lt;/span&gt;&lt;span class="s2"&gt;' is not installed."&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;done

&lt;/span&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PERL_USE_UNSAFE_INC&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1  &lt;span class="c"&gt;# Perl 5.26+ removed . from @INC&lt;/span&gt;

&lt;span class="c"&gt;# 1. Install deps (pure-perl only)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CLEAN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; 1 &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nb"&gt;local&lt;/span&gt;/ &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; &lt;span class="nb"&gt;local&lt;/span&gt;/
    cpm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-L&lt;/span&gt; &lt;span class="nb"&gt;local&lt;/span&gt; &lt;span class="nt"&gt;--target-perl&lt;/span&gt; 5.24.0 &lt;span class="nt"&gt;--pp&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# 2. Set up paths&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PERL5LIB&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$PWD&lt;/span&gt;/lib:&lt;span class="nv"&gt;$PWD&lt;/span&gt;/local/lib/perl5
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$PWD&lt;/span&gt;/local/bin:&lt;span class="nv"&gt;$PATH&lt;/span&gt;

&lt;span class="c"&gt;# 3. Force pure-perl backends&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;B_HOOKS_ENDOFSCOPE_IMPLEMENTATION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;PP
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;LIST_MOREUTILS_PP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;MOO_XS_DISABLE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PACKAGE_STASH_IMPLEMENTATION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;PP
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PARAMS_VALIDATE_IMPLEMENTATION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;PP
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PERL_JSON_BACKEND&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;JSON::PP
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PUREPERL_ONLY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1

&lt;span class="c"&gt;# 4. Verify compilation&lt;/span&gt;
perl &lt;span class="nt"&gt;-c&lt;/span&gt; bin/mycli &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1

&lt;span class="c"&gt;# 5. Trace&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CLEAN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; 1 &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; fatpacker.trace &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;perl &lt;span class="nt"&gt;-Ilib&lt;/span&gt; bin/trace-helper
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Trace: &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; &amp;lt; fatpacker.trace&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; modules"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# 6. Pack&lt;/span&gt;
fatpack packlists-for &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;fatpacker.trace&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; packlists
fatpack tree &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;packlists&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# Strip arch-specific dirs and non-essential files&lt;/span&gt;
&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; fatlib/&lt;span class="si"&gt;$(&lt;/span&gt;perl &lt;span class="nt"&gt;-MConfig&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'print $Config{archname}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
find fatlib &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s1"&gt;'*.pod'&lt;/span&gt; &lt;span class="nt"&gt;-delete&lt;/span&gt;
find fatlib &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s1"&gt;'*.pl'&lt;/span&gt;  &lt;span class="nt"&gt;-delete&lt;/span&gt;

&lt;span class="c"&gt;# Bundle&lt;/span&gt;
fatpack file bin/mycli &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; mycli-packed
&lt;span class="nb"&gt;chmod&lt;/span&gt; +x mycli-packed
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Built mycli-packed (&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &amp;lt; mycli-packed&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; bytes)"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step by step: what happens
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Prerequisites&lt;/strong&gt; -- verify &lt;code&gt;cpm&lt;/code&gt;, &lt;code&gt;fatpack&lt;/code&gt;, and &lt;code&gt;perl&lt;/code&gt; are available&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Install&lt;/strong&gt; -- &lt;code&gt;cpm&lt;/code&gt; installs all dependencies into &lt;code&gt;local/&lt;/code&gt; as pure
Perl, targeting 5.24.0&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Paths and env&lt;/strong&gt; -- set &lt;code&gt;PERL5LIB&lt;/code&gt;, &lt;code&gt;PATH&lt;/code&gt;, and pure-Perl overrides&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compile check&lt;/strong&gt; -- &lt;code&gt;perl -c bin/mycli&lt;/code&gt; catches syntax errors before
the slow trace step&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trace&lt;/strong&gt; -- the helper script loads everything and writes the module
list to &lt;code&gt;fatpacker.trace&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Packlists and tree&lt;/strong&gt; -- &lt;code&gt;fatpack packlists-for&lt;/code&gt; maps module names to
installed packlist files; &lt;code&gt;fatpack tree&lt;/code&gt; copies the &lt;code&gt;.pm&lt;/code&gt; files into
&lt;code&gt;fatlib/&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clean up&lt;/strong&gt; -- remove &lt;code&gt;.pod&lt;/code&gt;, &lt;code&gt;.pl&lt;/code&gt;, and arch-specific directories to
reduce size&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bundle&lt;/strong&gt; -- &lt;code&gt;fatpack file&lt;/code&gt; inlines everything from &lt;code&gt;fatlib/&lt;/code&gt; into the
script&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Makefile integration
&lt;/h2&gt;

&lt;p&gt;For teams that prefer &lt;code&gt;make&lt;/code&gt;, add targets that delegate to the shell script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight make"&gt;&lt;code&gt;&lt;span class="c"&gt;# In Makefile.PL, inside MY::postamble
&lt;/span&gt;&lt;span class="nl"&gt;.PHONY&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;pack clean_fatpack&lt;/span&gt;

&lt;span class="nl"&gt;pack&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    ./fatpack

&lt;span class="nl"&gt;clean &lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt; &lt;span class="nf"&gt;clean_fatpack&lt;/span&gt;

&lt;span class="nl"&gt;clean_fatpack&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; fatlib fatpacker.trace packlists mycli-packed &lt;span class="nb"&gt;local&lt;/span&gt;/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then building is just:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;perl Makefile.PL
make pack
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Adding a new dependency
&lt;/h2&gt;

&lt;p&gt;When someone adds &lt;code&gt;use Some::New::Module&lt;/code&gt; to the codebase, the fatpacked binary will break with &lt;code&gt;Can't locate Some/New/Module.pm in @INC&lt;/code&gt; unless the build picks it up. The workflow is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Add the module to &lt;code&gt;cpanfile&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;If the module is loaded at runtime (via &lt;code&gt;require&lt;/code&gt; or a plugin
mechanism), add a &lt;code&gt;require Some::New::Module&lt;/code&gt; line to the trace helper&lt;/li&gt;
&lt;li&gt;Rebuild with &lt;code&gt;--clean&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./fatpack &lt;span class="nt"&gt;--clean&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;--clean&lt;/code&gt; flag is important. Without it, the build reuses the cached &lt;code&gt;local/&lt;/code&gt; directory and &lt;code&gt;fatpacker.trace&lt;/code&gt; from the previous run. The new module won't appear in either, and the packed binary will silently ship without it.&lt;/p&gt;

&lt;p&gt;A good safeguard is to run &lt;code&gt;perl -c mycli-packed&lt;/code&gt; after every build -- this catches missing modules at build time rather than in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  What about perlstrip?
&lt;/h2&gt;

&lt;p&gt;Perl::Strip can reduce the packed file by ~30% by removing comments, POD, and whitespace from bundled modules. We deliberately left it off. For an internal tool, the size saving (~1.7 MB) is not worth the trade-off: stripped files are harder to debug with stack traces, and perlstrip has a known issue corrupting files that contain &lt;code&gt;use utf8&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas and tips
&lt;/h2&gt;

&lt;h3&gt;
  
  
  XS modules cannot be fatpacked
&lt;/h3&gt;

&lt;p&gt;Modules with C extensions (&lt;code&gt;.so&lt;/code&gt;/&lt;code&gt;.xs&lt;/code&gt;) cannot be inlined. They must already exist on the target system. If your app has many XS dependencies, consider PAR::Packer instead (see above).&lt;/p&gt;

&lt;h3&gt;
  
  
  PERL_USE_UNSAFE_INC
&lt;/h3&gt;

&lt;p&gt;Perl 5.26 removed &lt;code&gt;.&lt;/code&gt; from &lt;code&gt;@INC&lt;/code&gt;. Some older CPAN modules assume it's there during install or test. Set &lt;code&gt;PERL_USE_UNSAFE_INC=1&lt;/code&gt; during the build to avoid spurious failures. This only affects the build environment, not the packed binary.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pinto / private CPAN
&lt;/h3&gt;

&lt;p&gt;If your organisation runs a private CPAN mirror (Pinto, OrePAN2, etc.), point &lt;code&gt;cpm&lt;/code&gt; at it with &lt;code&gt;--resolver&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cpm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-L&lt;/span&gt; &lt;span class="nb"&gt;local&lt;/span&gt; &lt;span class="nt"&gt;--resolver&lt;/span&gt; 02packages,&lt;span class="nv"&gt;$PINTO_REPO&lt;/span&gt; &lt;span class="nt"&gt;--pp&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Docker builds
&lt;/h3&gt;

&lt;p&gt;FatPacker and Docker are complementary. Use Docker for the build environment (consistent Perl version, cpm, fatpack installed), and ship either the container image or just the packed file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; mycli-packed /usr/local/bin/mycli&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nb"&gt;chmod&lt;/span&gt; +x /usr/local/bin/mycli
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;The core recipe is three pieces:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A &lt;strong&gt;trace helper&lt;/strong&gt; that loads every module your app could use at runtime,
capturing the full dependency tree via &lt;code&gt;%INC&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pure-Perl enforcement&lt;/strong&gt; via environment variables and &lt;code&gt;cpm --pp&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;The standard &lt;strong&gt;fatpack pipeline&lt;/strong&gt;: packlists, tree, clean up, bundle&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The result is a single file you can &lt;code&gt;scp&lt;/code&gt; to any box with Perl 5.24+ and run immediately. No CPAN, no Makefile, no containers required.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://metacpan.org/pod/App::FatPacker" rel="noopener noreferrer"&gt;App::FatPacker&lt;/a&gt; on CPAN&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://cleverdomain.org/ye2018/" rel="noopener noreferrer"&gt;FatPacking Perl applications&lt;/a&gt; -- talk
by Andrew Rodland covering the core technique, pure-Perl enforcement, and
&lt;code&gt;cpm&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/arodland/swr/blob/master/fatpack" rel="noopener noreferrer"&gt;arodland/swr fatpack script&lt;/a&gt;
-- a clean, minimal reference implementation of the full pipeline&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://metacpan.org/pod/App::cpm" rel="noopener noreferrer"&gt;App::cpm&lt;/a&gt; -- fast CPAN installer
(itself shipped as a fatpacked binary); &lt;code&gt;--target-perl&lt;/code&gt; and &lt;code&gt;--pp&lt;/code&gt; flags
are essential for fatpack builds&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>programming</category>
      <category>perl</category>
      <category>cli</category>
      <category>sre</category>
    </item>
    <item>
      <title>Making Git tolerable</title>
      <dc:creator>Dean Hamstead</dc:creator>
      <pubDate>Wed, 11 Mar 2026 09:59:41 +0000</pubDate>
      <link>https://forem.com/perldean/making-git-tolerable-2dj1</link>
      <guid>https://forem.com/perldean/making-git-tolerable-2dj1</guid>
      <description>&lt;p&gt;Once you have &lt;a href="https://sapling-scm.com/" rel="noopener noreferrer"&gt;seen&lt;/a&gt; &lt;a href="https://github.com/jj-vcs/jj" rel="noopener noreferrer"&gt;better&lt;/a&gt; than git, you can never come back. But you can make it slightly more tolerable. &lt;/p&gt;

&lt;h1&gt;
  
  
  Extensions
&lt;/h1&gt;

&lt;p&gt;These two extensions will substantially increase your velocity, help you &lt;em&gt;flow&lt;/em&gt;, and keep you in the zone.&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;git-branchless&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;When forced to use Git I naturally use a stacked commit workflow to move much faster, cutting out the overhead of constant feature branching to keep my development process lean. Instead of wrestling with a web of branches, &lt;code&gt;git-branchless&lt;/code&gt; enables a "&lt;a href="https://www.stacking.dev/" rel="noopener noreferrer"&gt;stacked&lt;/a&gt;" approach where each atomic change is a single commit built directly on top of the last.&lt;/p&gt;

&lt;p&gt;The tool acts as a high-powered coordinator for this linear progression; it provides a &lt;code&gt;smartlog&lt;/code&gt; to visualize your local commit graph and navigation commands like &lt;em&gt;next&lt;/em&gt; and &lt;em&gt;prev&lt;/em&gt; to jump through your stack without manual checkouts. Most importantly, it handles the "restack" automatically—if you amend a commit in the middle of your stack, &lt;code&gt;git-branchless&lt;/code&gt; instantly rebases all descendant commits to keep the entire chain in sync. If you’re curious about how to implement this without the usual Git friction, &lt;a href="https://benjamincongdon.me/blog/2021/12/07/Branchless-Git/" rel="noopener noreferrer"&gt;Ben Congdon’s article&lt;/a&gt; is a fantastic introduction to the philosophy and the tools that make it work.&lt;/p&gt;

&lt;p&gt;See also the &lt;a href="https://web.archive.org/web/20210124163025/https://secure.phabricator.com/w/guides/arcanist_workflows/" rel="noopener noreferrer"&gt;original epriestly workflow&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  &lt;strong&gt;git-autofixup&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;To move even faster and keep my history clean, I use &lt;code&gt;git-autofixup&lt;/code&gt; to assist in organizing stacked commits. Instead of manually hunting for SHAs to fix up small edits or review feedback, this tool uses git blame to automatically assign unstaged changes to the correct commit in your stack. It essentially turns the tedious process of "tidying up" into a mechanical one, generating the necessary &lt;code&gt;fixup!&lt;/code&gt; commits so that a simple &lt;code&gt;git rebase --autosquash&lt;/code&gt; can perfectly integrate them. If you’re looking to streamline your workflow and avoid the friction of manual rebasing, &lt;a href="https://torbiak.com/post/autofixup/" rel="noopener noreferrer"&gt;Jordan Torbiak’s post&lt;/a&gt; on &lt;code&gt;git-autofixup&lt;/code&gt; is a great intro to the tool.&lt;/p&gt;




&lt;h1&gt;
  
  
  My Opinionated .gitconfig, Explained
&lt;/h1&gt;

&lt;p&gt;I've refined my &lt;code&gt;~/.gitconfig&lt;/code&gt; into something that is faster, safer, and produces cleaner output. Here's the whole thing, broken down section by section.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Full Config
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[advice]&lt;/span&gt;
    &lt;span class="py"&gt;defaultBranchName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;false&lt;/span&gt;
&lt;span class="nn"&gt;[blame]&lt;/span&gt;
    &lt;span class="py"&gt;coloring&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;highlightRecent&lt;/span&gt;
&lt;span class="nn"&gt;[alias]&lt;/span&gt;
    &lt;span class="py"&gt;checkout-remote&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"!f() { git checkout -b &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;$1&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;origin/$1&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;; }; f"&lt;/span&gt;
    &lt;span class="py"&gt;ci&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;commit&lt;/span&gt;
    &lt;span class="py"&gt;co&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;checkout&lt;/span&gt;
    &lt;span class="py"&gt;lg&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;log --graph --oneline --decorate --all&lt;/span&gt;
    &lt;span class="py"&gt;ls&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;show --pretty= --name-only HEAD&lt;/span&gt;
    &lt;span class="py"&gt;patch&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;!git --no-pager diff --no-color&lt;/span&gt;
    &lt;span class="py"&gt;resync&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"!f() { r=${1:-origin}; git fetch &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;$r&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt; &amp;amp;&amp;amp; git reset --hard &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;$r/$(git branch --show-current)&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;; }; f"&lt;/span&gt;
    &lt;span class="py"&gt;st&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;status&lt;/span&gt;
    &lt;span class="py"&gt;stashed&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;stash list --pretty=format:'%gd: %Cred%h%Creset %Cgreen[%ar]%Creset %s'&lt;/span&gt;
    &lt;span class="py"&gt;wip&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;commit -am 'WIP'&lt;/span&gt;
&lt;span class="nn"&gt;[branch]&lt;/span&gt;
    &lt;span class="py"&gt;autosetuprebase&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;
    &lt;span class="py"&gt;sort&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;-committerdate&lt;/span&gt;
&lt;span class="nn"&gt;[color]&lt;/span&gt;
    &lt;span class="py"&gt;ui&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;auto&lt;/span&gt;
&lt;span class="nn"&gt;[commit]&lt;/span&gt;
    &lt;span class="py"&gt;cleanup&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;scissors&lt;/span&gt;
    &lt;span class="py"&gt;verbose&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="nn"&gt;[core]&lt;/span&gt;
    &lt;span class="py"&gt;compression&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;9&lt;/span&gt;
    &lt;span class="py"&gt;fsmonitor&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
    &lt;span class="py"&gt;pager&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;diff-so-fancy | less --tabs=4 -RFX&lt;/span&gt;
    &lt;span class="py"&gt;preloadindex&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
    &lt;span class="py"&gt;untrackedCache&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="nn"&gt;[diff]&lt;/span&gt;
    &lt;span class="py"&gt;algorithm&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;histogram&lt;/span&gt;
    &lt;span class="py"&gt;colorMoved&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;dimmed_zebra&lt;/span&gt;
    &lt;span class="py"&gt;colorMovedWS&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;allow-indentation-change&lt;/span&gt;
    &lt;span class="py"&gt;context&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;10&lt;/span&gt;
    &lt;span class="py"&gt;mnemonicPrefix&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
    &lt;span class="py"&gt;noprefix&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
    &lt;span class="py"&gt;renames&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;copy&lt;/span&gt;
&lt;span class="nn"&gt;[fetch]&lt;/span&gt;
    &lt;span class="py"&gt;fsckobjects&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
    &lt;span class="py"&gt;parallel&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;0&lt;/span&gt;
    &lt;span class="py"&gt;prune&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
    &lt;span class="py"&gt;pruneTags&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="nn"&gt;[gc]&lt;/span&gt;
    &lt;span class="py"&gt;writeCommitGraph&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="nn"&gt;[grep]&lt;/span&gt;
    &lt;span class="py"&gt;patternType&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;perl&lt;/span&gt;
&lt;span class="nn"&gt;[init]&lt;/span&gt;
    &lt;span class="py"&gt;defaultBranch&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;master&lt;/span&gt;
&lt;span class="nn"&gt;[interactive]&lt;/span&gt;
    &lt;span class="py"&gt;diffFilter&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;diff-so-fancy --patch&lt;/span&gt;
&lt;span class="nn"&gt;[log]&lt;/span&gt;
    &lt;span class="py"&gt;date&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;iso&lt;/span&gt;
&lt;span class="nn"&gt;[merge]&lt;/span&gt;
    &lt;span class="py"&gt;conflictStyle&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;zdiff3&lt;/span&gt;
    &lt;span class="py"&gt;ff&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;only&lt;/span&gt;
    &lt;span class="py"&gt;keepbackup&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;false&lt;/span&gt;
&lt;span class="nn"&gt;[pack]&lt;/span&gt;
    &lt;span class="py"&gt;threads&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;0&lt;/span&gt;
&lt;span class="nn"&gt;[pager]&lt;/span&gt;
    &lt;span class="py"&gt;diff&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;diff-so-fancy | less --tabs=1,5 -RFX&lt;/span&gt;
    &lt;span class="py"&gt;log&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;diff-so-fancy | less --tabs=1,5 -RFX&lt;/span&gt;
    &lt;span class="py"&gt;show&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;diff-so-fancy | less --tabs=1,5 -RFX&lt;/span&gt;
&lt;span class="nn"&gt;[pull]&lt;/span&gt;
    &lt;span class="py"&gt;ff&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;only&lt;/span&gt;
    &lt;span class="py"&gt;rebase&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="nn"&gt;[push]&lt;/span&gt;
    &lt;span class="py"&gt;autoSetupRemote&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
    &lt;span class="py"&gt;default&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;simple&lt;/span&gt;
    &lt;span class="py"&gt;followtags&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="nn"&gt;[rebase]&lt;/span&gt;
    &lt;span class="py"&gt;autoStash&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
    &lt;span class="py"&gt;autosquash&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
    &lt;span class="py"&gt;missingCommitsCheck&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;error&lt;/span&gt;
    &lt;span class="py"&gt;updateRefs&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="nn"&gt;[receive]&lt;/span&gt;
    &lt;span class="py"&gt;fsckObjects&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="nn"&gt;[rerere]&lt;/span&gt;
    &lt;span class="py"&gt;autoupdate&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
    &lt;span class="py"&gt;enabled&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="nn"&gt;[tag]&lt;/span&gt;
    &lt;span class="py"&gt;sort&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;version:refname&lt;/span&gt;
&lt;span class="nn"&gt;[transfer]&lt;/span&gt;
    &lt;span class="py"&gt;fsckobjects&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="nn"&gt;[user]&lt;/span&gt;
    &lt;span class="py"&gt;email&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;my@email&lt;/span&gt;
    &lt;span class="py"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;Me&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now let's walk through it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Silencing Noise
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[advice]&lt;/span&gt;
    &lt;span class="py"&gt;defaultBranchName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every time you &lt;code&gt;git init&lt;/code&gt;, Git helpfully suggests configuring a default branch name. Once you've set one, you don't need the reminder. This turns it off.&lt;/p&gt;




&lt;h2&gt;
  
  
  Better Blame
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[blame]&lt;/span&gt;
    &lt;span class="py"&gt;coloring&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;highlightRecent&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Recent changes glow brighter, older ones fade into the background. When you &lt;code&gt;git blame&lt;/code&gt; a file, your eye is immediately drawn to what changed recently — which is usually what you're investigating.&lt;/p&gt;




&lt;h2&gt;
  
  
  Aliases That Earn Their Keep
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[alias]&lt;/span&gt;
    &lt;span class="py"&gt;checkout-remote&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"!f() { git checkout -b &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;$1&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;origin/$1&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;; }; f"&lt;/span&gt;
    &lt;span class="py"&gt;ci&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;commit&lt;/span&gt;
    &lt;span class="py"&gt;co&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;checkout&lt;/span&gt;
    &lt;span class="py"&gt;lg&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;log --graph --oneline --decorate --all&lt;/span&gt;
    &lt;span class="py"&gt;ls&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;show --pretty= --name-only HEAD&lt;/span&gt;
    &lt;span class="py"&gt;patch&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;!git --no-pager diff --no-color&lt;/span&gt;
    &lt;span class="py"&gt;resync&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"!f() { r=${1:-origin}; git fetch &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;$r&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt; &amp;amp;&amp;amp; git reset --hard &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;$r/$(git branch --show-current)&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s"&gt;; }; f"&lt;/span&gt;
    &lt;span class="py"&gt;st&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;status&lt;/span&gt;
    &lt;span class="py"&gt;stashed&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;stash list --pretty=format:'%gd: %Cred%h%Creset %Cgreen[%ar]%Creset %s'&lt;/span&gt;
    &lt;span class="py"&gt;wip&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;commit -am 'WIP'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The classics — &lt;code&gt;ci&lt;/code&gt;, &lt;code&gt;co&lt;/code&gt;, &lt;code&gt;st&lt;/code&gt; — need no explanation. The interesting ones:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;lg&lt;/code&gt;&lt;/strong&gt; gives you a compact, visual branch graph of your entire repo. It's the first thing I run when switching context to a project.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;checkout-remote&lt;/code&gt;&lt;/strong&gt; creates a local tracking branch in one command: &lt;code&gt;git checkout-remote feature-x&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;resync&lt;/code&gt;&lt;/strong&gt; is the nuclear option — it fetches and hard-resets your branch to match the remote. This is what you want with stacked diffs and all force pushes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;stashed&lt;/code&gt;&lt;/strong&gt; lists your stashes with color-coded hashes, relative timestamps, and messages. Far more readable than the default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;wip&lt;/code&gt;&lt;/strong&gt; is a quick checkpoint. Stage everything, commit with "WIP", keep moving. I &lt;code&gt;autosquash&lt;/code&gt; these away later, cherry-pick or amend them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;patch&lt;/code&gt;&lt;/strong&gt; outputs a clean diff with no pager or color, ready for piping to a file or another tool.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Branch Behaviour
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[branch]&lt;/span&gt;
    &lt;span class="py"&gt;autosetuprebase&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;
    &lt;span class="py"&gt;sort&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;-committerdate&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every new tracking branch is automatically configured for rebase on pull. Branch listings are sorted by most recently committed, not alphabetically — because "which branch did I touch last?" is almost always the question.&lt;/p&gt;




&lt;h2&gt;
  
  
  Commits
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[commit]&lt;/span&gt;
    &lt;span class="py"&gt;cleanup&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;scissors&lt;/span&gt;
    &lt;span class="py"&gt;verbose&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;verbose = true&lt;/code&gt; shows the full diff in your commit message editor. You can review exactly what you're committing while writing the message.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;cleanup = scissors&lt;/code&gt; is the real gem. Instead of stripping all lines starting with &lt;code&gt;#&lt;/code&gt; (the default), Git uses a scissors marker (&lt;code&gt;-- &amp;gt;8 --&lt;/code&gt;) to separate your message from the help text. This means you can freely use &lt;code&gt;#&lt;/code&gt; in commit messages — Markdown headings, &lt;code&gt;#1234&lt;/code&gt; issue references at the start of a line — without Git eating them.&lt;/p&gt;




&lt;h2&gt;
  
  
  Core Performance
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[core]&lt;/span&gt;
    &lt;span class="py"&gt;compression&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;9&lt;/span&gt;
    &lt;span class="py"&gt;fsmonitor&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
    &lt;span class="py"&gt;pager&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;diff-so-fancy | less --tabs=4 -RFX&lt;/span&gt;
    &lt;span class="py"&gt;preloadindex&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
    &lt;span class="py"&gt;untrackedCache&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Maximum zlib compression for objects (smaller repos, slightly slower writes — worth it). The filesystem monitor daemon, preloaded index, and untracked file cache all make &lt;code&gt;git status&lt;/code&gt; noticeably faster on large repos.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/so-fancy/diff-so-fancy" rel="noopener noreferrer"&gt;diff-so-fancy&lt;/a&gt; as the pager transforms Git's output from "functional" to "beautiful."&lt;/p&gt;




&lt;h2&gt;
  
  
  Diff Configuration
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[diff]&lt;/span&gt;
    &lt;span class="py"&gt;algorithm&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;histogram&lt;/span&gt;
    &lt;span class="py"&gt;colorMoved&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;dimmed_zebra&lt;/span&gt;
    &lt;span class="py"&gt;colorMovedWS&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;allow-indentation-change&lt;/span&gt;
    &lt;span class="py"&gt;context&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;10&lt;/span&gt;
    &lt;span class="py"&gt;mnemonicPrefix&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
    &lt;span class="py"&gt;noprefix&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
    &lt;span class="py"&gt;renames&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;copy&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is where I've spent the most time tuning.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;histogram&lt;/code&gt;&lt;/strong&gt; produces cleaner, more readable diffs than the default Myers algorithm.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;colorMoved = dimmed_zebra&lt;/code&gt;&lt;/strong&gt; highlights lines that were moved (not added/removed) with alternating dimmed colors. Combined with &lt;strong&gt;&lt;code&gt;colorMovedWS = allow-indentation-change&lt;/code&gt;&lt;/strong&gt;, it still detects moved code even when you've re-indented it (e.g., wrapping in a new block). These two together make refactoring diffs dramatically easier to review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;context = 10&lt;/code&gt;&lt;/strong&gt; shows 10 lines of surrounding context instead of the default 3. More context means fewer trips back to the source while reviewing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;noprefix&lt;/code&gt;&lt;/strong&gt; removes the &lt;code&gt;a/&lt;/code&gt; and &lt;code&gt;b/&lt;/code&gt; path prefixes so file paths are directly copy-pasteable. &lt;strong&gt;&lt;code&gt;mnemonicPrefix&lt;/code&gt;&lt;/strong&gt; replaces them with meaningful labels (&lt;code&gt;i/&lt;/code&gt; for index, &lt;code&gt;w/&lt;/code&gt; for working tree) when prefixes are shown.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;renames = copy&lt;/code&gt;&lt;/strong&gt; detects both file renames and copies.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Fetch: Integrity and Cleanliness
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[fetch]&lt;/span&gt;
    &lt;span class="py"&gt;fsckobjects&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
    &lt;span class="py"&gt;parallel&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;0&lt;/span&gt;
    &lt;span class="py"&gt;prune&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
    &lt;span class="py"&gt;pruneTags&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every fetched object is validated for integrity. Stale remote-tracking branches and tags are automatically cleaned up. Fetches from multiple remotes happen in parallel (&lt;code&gt;0&lt;/code&gt; = use all CPUs).&lt;/p&gt;




&lt;h2&gt;
  
  
  Keeping Things Fast
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[gc]&lt;/span&gt;
    &lt;span class="py"&gt;writeCommitGraph&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="nn"&gt;[pack]&lt;/span&gt;
    &lt;span class="py"&gt;threads&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The commit-graph file dramatically speeds up any operation that walks history — &lt;code&gt;git log&lt;/code&gt;, &lt;code&gt;git blame&lt;/code&gt;, &lt;code&gt;git merge-base&lt;/code&gt;. Repacking uses all available CPU cores.&lt;/p&gt;




&lt;h2&gt;
  
  
  Grep and Interactive
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[grep]&lt;/span&gt;
    &lt;span class="py"&gt;patternType&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;perl&lt;/span&gt;
&lt;span class="nn"&gt;[interactive]&lt;/span&gt;
    &lt;span class="py"&gt;diffFilter&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;diff-so-fancy --patch&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Perl-compatible regexes for &lt;code&gt;git grep&lt;/code&gt; — because life's too short for basic regex. Interactive operations like &lt;code&gt;git add -p&lt;/code&gt; also get the diff-so-fancy treatment.&lt;/p&gt;




&lt;h2&gt;
  
  
  Merge Strategy
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[merge]&lt;/span&gt;
    &lt;span class="py"&gt;conflictStyle&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;zdiff3&lt;/span&gt;
    &lt;span class="py"&gt;ff&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;only&lt;/span&gt;
    &lt;span class="py"&gt;keepbackup&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;zdiff3&lt;/code&gt;&lt;/strong&gt; is the best conflict style available. It shows the common ancestor alongside both sides of the conflict &lt;em&gt;and&lt;/em&gt; cleans up redundant lines that diff3 leaves behind. It makes conflicts significantly easier to resolve.&lt;/p&gt;

&lt;p&gt;Fast-forward only means no surprise merge commits. If the merge can't fast-forward, I want to know about it and make an explicit decision. No &lt;code&gt;.orig&lt;/code&gt; backup files cluttering the tree after conflict resolution.&lt;/p&gt;




&lt;h2&gt;
  
  
  Pull and Push
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[pull]&lt;/span&gt;
    &lt;span class="py"&gt;ff&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;only&lt;/span&gt;
    &lt;span class="py"&gt;rebase&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="nn"&gt;[push]&lt;/span&gt;
    &lt;span class="py"&gt;autoSetupRemote&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
    &lt;span class="py"&gt;default&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;simple&lt;/span&gt;
    &lt;span class="py"&gt;followtags&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pulls rebase by default, and only fast-forward. Pushes automatically set up remote tracking (&lt;code&gt;-u&lt;/code&gt; is no longer needed), only push the current branch, and automatically include relevant annotated tags.&lt;/p&gt;




&lt;h2&gt;
  
  
  Rebase: The Heart of the Workflow
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[rebase]&lt;/span&gt;
    &lt;span class="py"&gt;autoStash&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
    &lt;span class="py"&gt;autosquash&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
    &lt;span class="py"&gt;missingCommitsCheck&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;error&lt;/span&gt;
    &lt;span class="py"&gt;updateRefs&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a rebase-centric workflow, and these settings make it seamless:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;autoStash&lt;/code&gt;&lt;/strong&gt; stashes uncommitted changes before rebase and restores them after. No more "cannot rebase: you have unstaged changes."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;autosquash&lt;/code&gt;&lt;/strong&gt; automatically processes &lt;code&gt;fixup!&lt;/code&gt; and &lt;code&gt;squash!&lt;/code&gt; commits during interactive rebase. Combined with the &lt;code&gt;wip&lt;/code&gt; alias, this is how I keep a clean history.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;missingCommitsCheck = error&lt;/code&gt;&lt;/strong&gt; is a safety net — if you accidentally delete a line during interactive rebase (dropping a commit), Git aborts instead of silently losing work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;updateRefs&lt;/code&gt;&lt;/strong&gt; automatically updates stacked branch pointers during rebase. Essential if you work with stacked PRs.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Object Integrity Everywhere
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[receive]&lt;/span&gt;
    &lt;span class="py"&gt;fsckObjects&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="nn"&gt;[transfer]&lt;/span&gt;
    &lt;span class="py"&gt;fsckobjects&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Combined with &lt;code&gt;fetch.fsckobjects&lt;/code&gt;, this validates every object during every transfer — fetch, push, clone. Belt and suspenders for repository integrity.&lt;/p&gt;




&lt;h2&gt;
  
  
  Rerere: Reuse Recorded Resolution
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[rerere]&lt;/span&gt;
    &lt;span class="py"&gt;autoupdate&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
    &lt;span class="py"&gt;enabled&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One of Git's most underrated features. When you resolve a merge conflict, Git remembers the resolution. The next time the same conflict appears (common during repeated rebases), Git applies the same fix automatically and stages it. Once you enable this, you wonder how you ever lived without it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Tags and Logs
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[tag]&lt;/span&gt;
    &lt;span class="py"&gt;sort&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;version:refname&lt;/span&gt;
&lt;span class="nn"&gt;[log]&lt;/span&gt;
    &lt;span class="py"&gt;date&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;iso&lt;/span&gt;
&lt;span class="nn"&gt;[init]&lt;/span&gt;
    &lt;span class="py"&gt;defaultBranch&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;master&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tags sort by semantic version (&lt;code&gt;v2.10&lt;/code&gt; comes after &lt;code&gt;v2.9&lt;/code&gt;, not before &lt;code&gt;v2.2&lt;/code&gt;). Dates display in ISO 8601 format. New repos default to &lt;code&gt;master&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Pretty Pager Output
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[pager]&lt;/span&gt;
    &lt;span class="py"&gt;diff&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;diff-so-fancy | less --tabs=1,5 -RFX&lt;/span&gt;
    &lt;span class="py"&gt;log&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;diff-so-fancy | less --tabs=1,5 -RFX&lt;/span&gt;
    &lt;span class="py"&gt;show&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;diff-so-fancy | less --tabs=1,5 -RFX&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;diff-so-fancy for &lt;code&gt;diff&lt;/code&gt;, &lt;code&gt;log&lt;/code&gt;, and &lt;code&gt;show&lt;/code&gt; with specific tab settings. Combined with the &lt;code&gt;[color]&lt;/code&gt; and &lt;code&gt;[interactive]&lt;/code&gt; settings, every piece of Git output that involves diffs looks great.&lt;/p&gt;




&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;A good &lt;code&gt;.gitconfig&lt;/code&gt; is a force multiplier. Most of these settings cost nothing in terms of workflow changes — they just make the defaults smarter. The ones that &lt;em&gt;do&lt;/em&gt; change behaviour (rebase-by-default, fast-forward-only, scissors cleanup) are deliberate choices that enforce a cleaner Git history.&lt;/p&gt;

&lt;p&gt;Start with the defaults you have, add what makes sense, and revisit every few months. Git keeps shipping useful features that most people never discover.&lt;/p&gt;

&lt;h3&gt;
  
  
  Honorable Mention: Game of Trees
&lt;/h3&gt;

&lt;p&gt;Check out &lt;a href="https://gameoftrees.org/" rel="noopener noreferrer"&gt;Game of Trees&lt;/a&gt; - the git clone with and ISC license with a well conceived cli - which might be &lt;a href="https://gameoftrees.org/comparison.html" rel="noopener noreferrer"&gt;what the doctor orders&lt;/a&gt; for many people.&lt;/p&gt;

&lt;h3&gt;
  
  
  One Diff, One Thesis
&lt;/h3&gt;

&lt;p&gt;This is the key to velocity.&lt;/p&gt;

&lt;p&gt;

  &lt;iframe src="https://www.youtube.com/embed/HvA_lXp2mjs"&gt;
  &lt;/iframe&gt;


&lt;/p&gt;

</description>
      <category>git</category>
      <category>scm</category>
      <category>cli</category>
    </item>
    <item>
      <title>VSCode as a Perl IDE</title>
      <dc:creator>Dean Hamstead</dc:creator>
      <pubDate>Sat, 05 Jun 2021 20:23:11 +0000</pubDate>
      <link>https://forem.com/perldean/vscode-as-a-perl-ide-3cco</link>
      <guid>https://forem.com/perldean/vscode-as-a-perl-ide-3cco</guid>
      <description>&lt;h1&gt;
  
  
  Overview
&lt;/h1&gt;

&lt;p&gt;The VSCode IDE implements it's language support via the &lt;a href="https://langserver.org/"&gt;Language Server Protocol&lt;/a&gt; which is designed to cleanly separate language support from the editor. For Perl a fully functional language server is implemented in &lt;a href="https://metacpan.org/release/Perl-LanguageServer"&gt;Perl::LanguageServer&lt;/a&gt; and will work with any LSP capable editor. &lt;/p&gt;

&lt;p&gt;VSCode can edit both locally or in a remote environment. This brief guide will get you started editing locally (i.e. on your desktop or laptop), however the Perl VSCode extension and Perl's LanguageServer will work in either scenario.&lt;/p&gt;

&lt;h1&gt;
  
  
  Install a Perl interpreter for development
&lt;/h1&gt;

&lt;p&gt;&lt;strong&gt;Although a Perl interpreter is likely already installed&lt;/strong&gt; (i.e. system perl) on Linux , BSD, or MacOS the best practice is to leave if for the system to use and install your own interpreter for your development work. On Windows you likely will install Strawberry Perl.&lt;/p&gt;

&lt;p&gt;Please follow &lt;a href="https://www.perl.com/article/downloading-and-installing-perl-in-2021/"&gt;this guide&lt;/a&gt; to installing a perl interpreter for your development.&lt;/p&gt;

&lt;h1&gt;
  
  
  Install Perl's LanguageServer
&lt;/h1&gt;

&lt;p&gt;Assuming cpanm is installed, simply run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cpanm Perl::LanguageServer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. The Perl LanguageServer is now ready.&lt;/p&gt;

&lt;h1&gt;
  
  
  Install VSCode (actually VSCodium)
&lt;/h1&gt;

&lt;p&gt;&lt;a href="https://vscodium.com/"&gt;VSCodium&lt;/a&gt; is the fully-foss version of VSCode (think Chromium). I recommend you install VSCodium rather than VSCode.&lt;/p&gt;

&lt;p&gt;Fedora, SuSE, and the Debian family of Linux distributions can install from &lt;a href="https://gitlab.com/paulcarroty/vscodium-deb-rpm-repo"&gt;this maintained repo&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;For other OS including MacOS and Windows simply pick your favourite approach from the &lt;a href="https://github.com/VSCodium/vscodium#download-install"&gt;official install instructions&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Install the Perl extension for VSCode
&lt;/h1&gt;

&lt;p&gt;Run VSCode (VSCodium) and using the familiar "File -&amp;gt; Open" interface, open a Perl source file. Observe that whilst syntax highlighting is available already via the built-in "Perl Language Basics" plugin, actual IDE functions do not - i.e. try "Run -&amp;gt; Start Debugging".&lt;/p&gt;

&lt;p&gt;We need to configure VSCode to use our Perl LanguageServer by installing the &lt;a href="https://marketplace.visualstudio.com/items?itemName=richterger.perl"&gt;Perl extension&lt;/a&gt;. The good news is that the extension is in the official VS Marketplace making it very quick and easy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In actual VSCode&lt;/strong&gt; the quickest route is hit &lt;code&gt;Ctrl-P&lt;/code&gt; then type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ext install richterger.perl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You could also click "File -&amp;gt; Preferences -&amp;gt; Extensions" then search for "perl" and click install.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In VSCodium&lt;/strong&gt; the &lt;a href="https://open-vsx.org/"&gt;Open VSX registry&lt;/a&gt; is used which doesn't &lt;a href="https://github.com/richterger/Perl-LanguageServer/issues/98"&gt;yet&lt;/a&gt; include the perl extensions.&lt;/p&gt;

&lt;p&gt;From the VS Marketplace &lt;a href="https://marketplace.visualstudio.com/items?itemName=richterger.perl"&gt;plugin page&lt;/a&gt; download the extension with the "Download Extension" link on the right hand side.&lt;/p&gt;

&lt;p&gt;On your local system's command line (there is no need to close VSCodium), change directory to your downloads and run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;codium --install-extension richterger.perl-*.vsix 
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Returning to VSCode or VSCodium, observe that the Perl extension is now installed in the extensions list ("File -&amp;gt; Preferences -&amp;gt; Extensions" or &lt;code&gt;Ctrl-Shift-X&lt;/code&gt;)&lt;/p&gt;

&lt;p&gt;Open a Perl source file, click "Run -&amp;gt; Start Debugging" or hit &lt;code&gt;F5&lt;/code&gt; and observe there is no error as before.&lt;/p&gt;

&lt;p&gt;Now explore all VSCocde IDE functions working nicely with Perl!&lt;/p&gt;

</description>
      <category>perl</category>
      <category>programming</category>
      <category>vscode</category>
      <category>vscodium</category>
    </item>
  </channel>
</rss>
