diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,5 @@
+# Revision history for palo-alto-syslog
+
+## 0.1.0.0 -- 2020-01-15
+
+* Initial release.
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,30 @@
+Copyright (c) 2019, Andrew Martin
+
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+    * Redistributions of source code must retain the above copyright
+      notice, this list of conditions and the following disclaimer.
+
+    * Redistributions in binary form must reproduce the above
+      copyright notice, this list of conditions and the following
+      disclaimer in the documentation and/or other materials provided
+      with the distribution.
+
+    * Neither the name of Andrew Martin nor the names of other
+      contributors may be used to endorse or promote products derived
+      from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/Setup.hs b/Setup.hs
new file mode 100644
--- /dev/null
+++ b/Setup.hs
@@ -0,0 +1,2 @@
+import Distribution.Simple
+main = defaultMain
diff --git a/bench/Main.hs b/bench/Main.hs
new file mode 100644
--- /dev/null
+++ b/bench/Main.hs
@@ -0,0 +1,13 @@
+import Gauge (bench,whnf,defaultMain)
+import Panos.Syslog (decode)
+
+import qualified Sample as S
+
+main :: IO ()
+main = defaultMain
+  [ bench "8-1-Traffic-A" (whnf decode S.traffic_8_1_A)
+  , bench "8-1-Threat-A" (whnf decode S.threat_8_1_A)
+  , bench "8-1-Threat-B" (whnf decode S.threat_8_1_B)
+  , bench "8-1-Threat-C" (whnf decode S.threat_8_1_C)
+  ]
+
diff --git a/common/Sample.hs b/common/Sample.hs
new file mode 100644
--- /dev/null
+++ b/common/Sample.hs
@@ -0,0 +1,226 @@
+{-# language TypeApplications #-}
+
+module Sample
+  ( traffic_8_1_A
+  , traffic_8_1_B
+  , threat_8_1_A
+  , threat_8_1_B
+  , threat_8_1_C
+  , threat_8_1_D
+  , threat_8_1_E
+  , threat_8_1_F
+  , threat_8_1_G
+  , threat_8_1_H
+  , threat_8_1_I
+  , system_8_1_A
+  ) where
+
+import Data.Bytes (Bytes)
+import Data.Word (Word8)
+import Data.Char (ord)
+import qualified Data.Bytes as Bytes
+import qualified GHC.Exts as Exts
+
+-- Sample Logs. If you add a sample log to this file, please
+-- replace all information in the log that could possibly be
+-- meaningful. At a bare minimum, this means:
+--
+-- * Replace any IP addresses with non-routable addresses 
+--   from the TEST-NET-1 block (192.0.2.0/24).
+-- * Replace any domain names with the reserved domain
+--   name example.com.
+-- * Replace any hostnames with something like MY-HOST
+--   or NY-APP or SAMPLE-HOST.
+-- * Replace rule names.
+
+pack :: String -> Bytes
+pack = Bytes.fromByteArray . Exts.fromList . map (fromIntegral @Int @Word8 . ord)
+
+-- Traffic Log 
+traffic_8_1_A :: Bytes
+traffic_8_1_A = pack $ concat
+  [ "<12> Jul 14 11:22:29 MY-HOST.example.com 1,2019/07/14 10:26:22,"
+  , "003924147953,TRAFFIC,end,2057,2019/07/14 10:20:24,192.0.2.235,"
+  , "192.0.2.251,0.0.0.0,0.0.0.0,Example-Rule-Name,,,incomplete,"
+  , "vsys45,My-Source-Zone,My-Dest-Zone,eth-inbound.42,eth-outbound.43,"
+  , "Example-Forwarding-Profile,2019/07/14 10:19:25,33651,1,59061,177,"
+  , "0,0,0x19,tcp,deny,135,101,34,1,2019/07/14 10:18:15,0,my-category,"
+  , "0,5384825641,0x8000000000000000,10.0.0.0-10.255.255.255,"
+  , "10.0.0.0-10.255.255.255,0,1,0,policy-deny,174,0,0,0,MY-VSYS-NAME,"
+  , "MY-DEVICE-NAME,from-policy,,,0,,0,,N/A,0,0,0,0"
+  ]
+
+traffic_8_1_B :: Bytes
+traffic_8_1_B = pack $ concat
+  [ "<13> Nov 1 11:26:30 TX-PAN-FW-1.example.org 1,2019/10/01 11:29:30,001922410172,"
+  , "TRAFFIC,end,2067,2019/11/01 11:26:30,192.0.2.165,192.0.2.100,"
+  , "192.0.2.130,192.0.2.100,Some-Rule,example\\jdoe,,google-base,"
+  , "vsys13,ORG-TX-Private,ORG-TX-Public,ethernet9/4.57,ethernet7/3,"
+  , "Forward-Logs,2019/10/01 11:33:34,267134,1,59173,443,5532,443,0x400053,"
+  , "tcp,allow,19421,2151,17270,39,2019/10/01 11:29:17,0,search-engines,0,"
+  , "2176523185,0x8000000000000000,10.0.0.0-10.255.255.255,United States,0,"
+  , "18,21,tcp-fin,134,0,0,0,Some-Name,TX-PAN-FW-1,my-policy,,,0,,0,,N/A,"
+  , "0,0,0,0"
+  ]
+
+-- Threat log for web browsing
+threat_8_1_A :: Bytes
+threat_8_1_A = pack $ concat
+  [ "<13> Jul 23 15:45:11 NY-APP-8.local 1,2019/07/23 15:45:11,"
+  , "028191718331,THREAT,url,2056,2019/07/23 15:45:12,192.0.2.240,"
+  , "192.0.2.243,192.0.2.54,192.0.2.13,MY-RULE-NAME,,,ssl,vsys79,"
+  , "src-zone,dst-zone,eth3/2.7,eth6/4,Example-Forwarding-Policy,"
+  , "2019/07/23 15:45:17,95963,2,59457,443,3984,443,0x400000,tcp,"
+  , "alert,\"www.example.com/string/\"\"hello\"\"\",(9999),"
+  , "the-category-name,informational,client-to-server,"
+  , "97089310,0xa000000000000000,10.0.0.0-10.255.255.255,United States,"
+  , "0,,0,,,0,,,,,,,,0,225,0,0,0,,NY-APP-8,,,,,0,,0,,N/A,unknown,"
+  , "AppThreat-1-6,0x0,0,3176142484,"
+  ]
+
+-- Threat log for web browsing
+threat_8_1_B :: Bytes
+threat_8_1_B = pack $ concat
+  [ "<13> Jul 24 08:38:34 THE-FW-3.local 1,2019/07/24 08:38:34,"
+  , "293471355489,THREAT,url,3179,2019/07/24 08:38:35,192.0.2.99,"
+  , "192.0.2.57,192.0.2.53,192.0.2.11,MY-WEB-TRAFFIC-RULE,,,"
+  , "web-browsing,vsys46,the-src-zone,the-dst-zone,ethernet13/6.721,"
+  , "ethernet12/5,the-policy-name,2019/07/24 08:38:36,140720,1,39537,80,"
+  , "55176,80,0x40b000,tcp,alert,\"www.example.com/\",(9999),"
+  , "internet-communications-and-telephony,informational,client-to-server,"
+  , "787958469,0xa000000000000000,10.0.0.0-10.255.255.255,United States,"
+  , "0,text/html,0,,,107,"
+  , "\"Mozilla/8.2 (Macintosh; Intel Mac OS X 10_19_7) AppleWebKit/943.87 "
+  , "(KHTML, like Gecko) Chrome/95.17.6820.145 Safari/922.12\","
+  , ",,,,,,"
+  , "0,134,0,0,0,the-vsys-name,THE-FW-HOST,,,,get,0,,0,,N/A,unknown,"
+  , "AppThreat-3-7,0x0,0,5323721019,"
+  ]
+
+-- Threat log for endpoint mapper detection, omitting syslog priority
+-- from the header.
+threat_8_1_C :: Bytes
+threat_8_1_C = pack $ concat
+  [ "<14> Jul 24 12:12:23 THE-FW-4.local 1,2019/07/24 12:12:22,"
+  , "012001011326,THREAT,vulnerability,2049,2019/07/24 12:12:35,"
+  , "192.0.2.3,192.0.2.3,0.0.0.0,0.0.0.0,SOME-RULE,,,"
+  , "msrpc-base,vsys413,my-src-zone,my-dst-zone,eth5/4.2,tunnel.2341,"
+  , "Example-Forward-Policy,2019/07/24 12:12:39,60519,1,61242,142,0,"
+  , "0,0x2000,tcp,alert,\"\",Microsoft RPC Endpoint Mapper Detection(30845),"
+  , "any,informational,client-to-server,89814684,0xa000000000000000,"
+  , "10.0.0.0-10.255.255.255,10.0.0.0-10.255.255.255,0,,0,,,0,,,,,,,,"
+  , "0,225,0,0,0,,THE-FW-4-HOST,,,,,0,,0,,N/A,info-leak,AppThreat-8174-5569,"
+  , "0x0,0,6279943187,"
+  ]
+
+-- Threat log for email
+threat_8_1_D :: Bytes
+threat_8_1_D = pack $ concat
+  [ "<13>Nov  3 07:01:51 BIG-OL-PAN.example.com 1,2019/11/03 07:01:51,"
+  , "008724449461,THREAT,file,2049,2019/11/03 07:01:51,192.0.2.105,"
+  , "192.0.2.106,192.0.2.107,192.0.2.108,My-Email-Rule,,,smtp,vsys17,"
+  , "MY-ZN,YOUR-ZN,ae2,ae13.5,Syslog,2019/11/03 07:01:51,206756,1,31532,"
+  , "25,42813,25,0x406000,tcp,alert,\"\",Email Link(52143),any,low,"
+  , "client-to-server,87562570,0xa000000000000000,United States,"
+  , "United States,0,,0,,,1,,,,"
+  , ",\"From: \"\"John Doe\"\" <jdoe@example.com>\",\"Sub: Hello, Worlds\","
+  , "To: <foo@bar.org>;  ,0,11,0,0,0,,BIG-OL-PAN,,,,,0,,0,,N/A,unknown,"
+  , "AppThreat-8194-5693,0x0,0,5382217271,"
+  ]
+
+
+-- Threat log for windows executable
+threat_8_1_E :: Bytes
+threat_8_1_E = pack $ concat
+  [ "<13>Nov  3 07:28:05 bigbox.example.com 1,2019/11/03 07:28:04,"
+  , "003942130261,THREAT,file,2049,2019/11/03 07:28:04,192.0.2.11,"
+  , "192.0.2.12,0.0.0.0,0.0.0.0,Alpha-Rule,,,ms-ds-smbv2,vsys13,"
+  , "Alpha-Zone,Beta-Zone,ae4.133,ae3,Syslog,2019/11/03 07:28:04,"
+  , "187912,2,53181,445,0,0,0x6000,tcp,alert,\"SylinkDrop.exe\","
+  , "Windows Executable (EXE)(52020),any,low,server-to-client,"
+  , "96402817,0xa000000000000000,10.0.0.0-10.255.255.255,"
+  , "10.0.0.0-10.255.255.255,0,,0,,,0,,,,,,,,0,11,0,0,0,,bigbox,"
+  , ",,,,0,,0,,N/A,unknown,AppThreat-8217-3140,0x0,0,4794968105,"
+  ]
+
+-- Threat log for tmp file
+threat_8_1_F :: Bytes
+threat_8_1_F = pack $ concat
+  [ "<13>Nov  3 07:37:10 fw-3.example.com 1,2019/11/03 07:37:09,001561170391,"
+  , "THREAT,file,2049,2019/11/03 07:37:09,192.0.2.55,192.0.2.58,192.0.2.57,"
+  , "192.0.2.56,Some-Rule,,,web-browsing,vsys51,ZONE-A,ZONE-B,ae5.67,ae8,"
+  , "Syslog,2019/11/03 07:37:09,48536,2,49619,80,24617,80,0x406000,tcp,"
+  , "alert,\"temp.tmp\",Temporary TMP File(52228),computer-and-internet-info,"
+  , "low,server-to-client,78561201,0xa000000000000000,10.0.0.0-10.255.255.255,"
+  , "United States,0,,0,,,1,,,,,,,,0,11,0,0,0,,fw-3,"
+  , "download.garmin.com/garmindlm/temp.tmp,,,,0,,0,,N/A,unknown,"
+  , "AppThreat-9311-6102,0x0,0,2694915680,"
+  , "download.garmin.com/garmindlm/temp.tmp"
+  ]
+
+-- Threat log for PHP file
+threat_8_1_G :: Bytes
+threat_8_1_G = pack $ concat
+  [ "<13>Nov  7 13:44:45 fw-4.example.com 1,2019/11/07 13:44:44,012301030273,"
+  , "THREAT,file,2049,2019/11/07 13:44:44,192.0.2.3,192.0.2.4,192.0.2.5,"
+  , "192.0.2.6,My-Rule,example\\jdoe,,web-browsing,vsys34,A-Zone,B-Zone,"
+  , "ethernet1/1.201,ethernet1/6,Forward-Logs,2019/11/07 13:44:44,257969,"
+  , "6,57863,80,29036,80,0x402000,tcp,alert,\"checksync.php\","
+  , "Hypertext Preprocessor PHP File(52256),web-advertisements,low,"
+  , "server-to-client,241048149,0xa000000000000000,10.0.0.0-10.255.255.255,"
+  , "United States,0,,0,,,2,,,,,,,,0,134,0,0,0,Staging,fw-4,\"contextual."
+  , "media.net/checksync.php?&vsSync=1&cs=1&hb=1&cv=37&ndec=1&cid=8HBJW752U"
+  , "&prvid=41,108,141,181,192,3007,3008&refUrl=http://www.msn.com&"
+  , "rtime=4\",,,,0,,0,,N/A,unknown,AppThreat-8307-9861,0x0,0,"
+  , "4294967295,\"contextual.media.net/\""
+  ]
+
+-- URL threat log. Regression test. Also, the serial number in this
+-- log has non-numeric characters.
+threat_8_1_H :: Bytes
+threat_8_1_H = pack $ concat
+  [ "<14>Dec 10 19:11:09 fw-4.example.com 1,2019/12/10 19:11:08,002926C74589,"
+  , "THREAT,url,2049,2019/12/10 19:11:08,192.0.2.123,192.0.2.125,192.0.2.126,"
+  , "192.0.2.127,Some-Rule,example\\mbjordan,,web-browsing,vsys1,"
+  , "X-Zone,Y-Zone,ethernet2/4.234,ethernet6/7,Forward-Logs,"
+  , "2019/12/10 19:11:08,241982,1,59055,80,2039,80,0x403000,tcp,"
+  , "alert,\"i.nflcdn.com/static/site/7.5/img/fonts/endzone-sans/"
+  , "medium-cond.woff\",(9999),content-delivery-networks,informational,"
+  , "client-to-server,257316637,0xa000000000000000,10.0.0.0-10.255.255.255,"
+  , "United States,0,,0,,,1,\"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
+  , "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.108 Safari/"
+  , "537.36\",,,\"http://combine.nflcdn.com/yui/min2/index.php?7.5"
+  , "hotfix-7.5.146&b=yui3%2Fstatic%2F7.5%2Fscripts%2Fmodules&f=font-"
+  , "endzonesans-condmedium/font-endzonesans-condmedium.css,font-"
+  , "endzoneslab-medium/font-endzoneslab-medium.css,font-endzoneslab-"
+  , "bold/font-endzoneslab-bold.css,font-endzonetech-medium/font-"
+  , "endzonetech-medium.css,font-endzonetech-bold/font-endzonetech-"
+  , "bold.css,font-endzonesans-bold/font-endzonesans-bold.css\",,,,"
+  , "0,134,0,0,0,Production,GA-PA-FW-1,,,,get,0,,0,,N/A,unknown,"
+  , "AppThreat-0-0,0x0,0,4380241866,"
+  ]
+
+-- Spyware threat log.
+threat_8_1_I :: Bytes
+threat_8_1_I = pack $ concat
+  [ "<12>Jan 10 01:56:39 NY-PA-FW-1.example 1,2020/01/10 01:56:38,"
+  , "012862319694,THREAT,spyware,2049,2020/01/10 01:56:38,192.0.2.15,"
+  , "192.0.2.30,192.0.2.31,192.0.2.32,Some-Rule,,,dns,vsys1,Src-Zone,"
+  , "Dst-Zone,ethernet1/9.255,ethernet1/1,Forward-Logs,"
+  , "2020/01/10 01:56:38,146414,1,45476,53,15021,53,0x80402000,udp,"
+  , "sinkhole,\"\",Suspicious DNS Query (generic:comparetvs.net)(320511141),"
+  , "any,medium,client-to-server,267287993,0xa000000000000000,"
+  , "10.0.0.0-10.255.255.255,United States,0,,1315498975425810792,,,"
+  , "0,,,,,,,,0,134,0,0,0,Production,GA-PA-FW-1,,,,,0,,0,,N/A,dns,"
+  , "AppThreat-3218-3729,0x0,0,4294967295,"
+  ]
+
+-- System log (IKE delete)
+system_8_1_A :: Bytes
+system_8_1_A = pack $ concat
+  [ "<14>Nov  9 19:53:08 NY-DC-FW-2.example.com 1,2019/10/06 15:46:26,"
+  , "009732949126,SYSTEM,vpn,0,2019/10/19 15:39:29,,ike-send-p2-delete,"
+  , "To-FOO-BAR-NET,0,0,general,informational,\"IKE protocol IPSec SA "
+  , "delete message sent to peer. SPI:0xA1CD910F.\",18249042,"
+  , "0x8000000000000000,0,0,0,0,,NY-DC-FW-2"
+  ]
diff --git a/pan-os-syslog.cabal b/pan-os-syslog.cabal
new file mode 100644
--- /dev/null
+++ b/pan-os-syslog.cabal
@@ -0,0 +1,96 @@
+cabal-version: 2.2
+name: pan-os-syslog
+version: 0.1.0.0
+synopsis: Parse syslog traffic from PAN-OS
+description:
+  Parse syslog traffic from PAN-OS. The data types in this library are
+  optimized for decoding logs, not for creating them. On consumer-grade
+  hardware, the benchmark suite demonstrates that 500-byte traffic logs are
+  parsed in under one microsecond. Contributions are welcome. This project's
+  goals are:
+  .
+  * Support as many PAN-OS syslog types as possible: traffic,
+    threat, hip-match, etc.
+  .
+  * Support as many versions of PAN-OS as possible: 8.0, 8.1, 9.0, etc.
+  .
+  * High performance. This library strives to avoid unneeded allocations.
+    Some allocations cannot be avoided. For example, it is necessary to
+    allocate space for the results. 
+  .
+  * Do a minimum amount of useful work on each field. The reasoning is
+    that users will typically discard most of the fields, so there is
+    no point wasting clock cycles doing unneeded work. Its hard to define
+    what this is precisely. Roughly, the rule this library follows is that
+    integral fields are parsed as @Word64@, and non-integral fields are
+    @Bytes@. This library does not attempt to validate hostnames, URIs, etc.
+  .
+  A good way to think about this library is that it is kind of like
+  a tokenizer. It is the first step when parsing PAN-OS logs into
+  some application-specific data type. There almost certainly needs
+  to be a second step to decodes fields that are actually of interest
+  to an application. This second step may involve validating URIs,
+  splitting the user domain and user name, etc.
+homepage: https://github.com/layer-3-communications/palo-alto-syslog
+bug-reports: https://github.com/layer-3-communications/palo-alto-syslog/issues
+license: BSD-3-Clause
+license-file: LICENSE
+author: Andrew Martin
+maintainer: amartin@layer3com.com
+copyright: 2019 Layer 3 Communications
+category: Network
+extra-source-files: CHANGELOG.md
+
+library
+  exposed-modules:
+    Panos.Syslog
+    Panos.Syslog.System
+    Panos.Syslog.Traffic
+    Panos.Syslog.Threat
+    Panos.Syslog.Unsafe
+  build-depends:
+    , base >=4.12.0.0 && <5
+    , byteslice >=0.1.3 && <0.3
+    , bytesmith >=0.3.1 && <0.4
+    , chronos >=1.0.6 && <1.1
+    , ip >=1.6 && <1.8
+    , primitive >=0.7 && <0.8
+    , primitive-addr >=0.1.0.2 && <2
+    , run-st >=0.1 && <0.2
+  hs-source-dirs: src
+  ghc-options: -Wall -O2
+  default-language: Haskell2010
+
+test-suite test
+  type: exitcode-stdio-1.0
+  hs-source-dirs: test, common
+  main-is: Main.hs
+  other-modules:
+    Sample
+  build-depends:
+    , base
+    , pan-os-syslog
+    , primitive
+    , byteslice
+  ghc-options: -Wall -O2
+  default-language: Haskell2010
+
+benchmark bench
+  type: exitcode-stdio-1.0
+  main-is: Main.hs
+  other-modules: Sample
+  hs-source-dirs:
+      bench
+    , common
+  build-depends:
+    , base
+    , byteslice
+    , gauge
+    , primitive
+    , pan-os-syslog
+  ghc-options: -O2 -Wall
+  default-language: Haskell2010
+
+source-repository head
+  type: git
+  location: git://github.com/layer-3-communications/pan-os-syslog.git
diff --git a/src/Panos/Syslog.hs b/src/Panos/Syslog.hs
new file mode 100644
--- /dev/null
+++ b/src/Panos/Syslog.hs
@@ -0,0 +1,12 @@
+module Panos.Syslog
+  ( -- * Types
+    U.Log(..)
+  , U.Traffic
+  , U.Threat
+  , U.System
+  , U.Field
+    -- * Decoding
+  , U.decode
+  ) where
+
+import qualified Panos.Syslog.Unsafe as U
diff --git a/src/Panos/Syslog/System.hs b/src/Panos/Syslog/System.hs
new file mode 100644
--- /dev/null
+++ b/src/Panos/Syslog/System.hs
@@ -0,0 +1,87 @@
+{-# language BangPatterns #-}
+{-# language MagicHash #-}
+{-# language NamedFieldPuns #-}
+{-# language DuplicateRecordFields #-}
+{-# language NumericUnderscores #-}
+{-# language DerivingStrategies #-}
+{-# language GeneralizedNewtypeDeriving #-}
+
+-- | Fields for system logs.
+module Panos.Syslog.System
+  ( description
+  , deviceName
+  , eventId
+  , module_
+  , object
+  , sequenceNumber
+  , serialNumber
+  , severity
+  , subtype
+  , timeGenerated
+  ) where
+
+import Data.Bytes.Types (Bytes(..))
+import Data.Word (Word64)
+import Panos.Syslog.Unsafe (System(System),Bounds(Bounds))
+import Chronos (Datetime)
+import qualified Panos.Syslog.Unsafe as U
+
+-- | Subtype of the system log; refers to the system daemon
+-- generating the log; values are @crypto@, @dhcp@, @dnsproxy@,
+-- @dos@, @general@, @global-protect@, @ha@, @hw@, @nat@, @ntpd@,
+-- @pbf@, @port@, @pppoe@, @ras@, @routing@, @satd@, @sslmgr@,
+-- @sslvpn@, @userid@, @url-filtering@, @vpn@.
+subtype :: System -> Bytes
+subtype (System{subtype=Bounds off len,message=msg}) =
+  Bytes{offset=off,length=len,array=msg}
+
+-- | The hostname of the firewall on which the session was logged.
+deviceName :: System -> Bytes
+deviceName (System{deviceName=Bounds off len,message=msg}) =
+  Bytes{offset=off,length=len,array=msg}
+
+-- | Name of the object associated with the system event.
+object :: System -> Bytes
+object (System{object=Bounds off len,message=msg}) =
+  Bytes{offset=off,length=len,array=msg}
+
+-- | This field is valid only when the value of the @subtype@
+-- field is @general@. It provides additional information about
+-- the sub-system generating the log; values are @general@,
+-- @management@, @auth@, @ha@, @upgrade@, @chassis@.
+module_ :: System -> Bytes
+module_ (System{module_=Bounds off len,message=msg}) =
+  Bytes{offset=off,length=len,array=msg}
+
+-- | Severity associated with the event; values are @informational@,
+-- @low@, @medium@, @high@, @critical@.
+severity :: System -> Bytes
+severity (System{severity=Bounds off len,message=msg}) =
+  Bytes{offset=off,length=len,array=msg}
+
+-- | String showing the name of the event.
+eventId :: System -> Bytes
+eventId (System{eventId=Bounds off len,message=msg}) =
+  Bytes{offset=off,length=len,array=msg}
+
+-- | Detailed description of the event, up to a maximum of 512 bytes.
+description :: System -> Bytes
+description (System{descriptionBounds=Bounds off len,descriptionByteArray=m}) =
+  Bytes{offset=off,length=len,array=m}
+
+-- | Time the log was generated on the dataplane.
+timeGenerated :: System -> Datetime
+timeGenerated = U.timeGenerated
+
+-- | A 64-bit log entry identifier incremented sequentially;
+-- each log type has a unique number space.
+sequenceNumber :: System -> Word64
+sequenceNumber = U.sequenceNumber
+
+-- | Serial number of the firewall that generated the log. These
+-- occassionally contain non-numeric characters, so do not attempt
+-- to parse this as a decimal number.
+serialNumber :: System -> Bytes
+serialNumber (System{serialNumber=Bounds off len,message=msg}) =
+  Bytes{offset=off,length=len,array=msg}
+
diff --git a/src/Panos/Syslog/Threat.hs b/src/Panos/Syslog/Threat.hs
new file mode 100644
--- /dev/null
+++ b/src/Panos/Syslog/Threat.hs
@@ -0,0 +1,240 @@
+{-# language BangPatterns #-}
+{-# language MagicHash #-}
+{-# language NamedFieldPuns #-}
+{-# language DuplicateRecordFields #-}
+{-# language NumericUnderscores #-}
+{-# language DerivingStrategies #-}
+{-# language GeneralizedNewtypeDeriving #-}
+
+-- | Fields for threat logs.
+module Panos.Syslog.Threat
+  ( action
+  , application
+  , category
+  , contentVersion
+  , destinationAddress
+  , destinationCountry
+  , destinationPort
+  , destinationUser
+  , destinationZone
+  , deviceName
+  , httpHeaders
+  , httpMethod
+  , inboundInterface
+  , miscellaneous
+  , natDestinationIp
+  , natDestinationPort
+  , natSourceIp
+  , natSourcePort
+  , outboundInterface
+  , recipient
+  , referer
+  , ruleName
+  , sender
+  , sequenceNumber
+  , serialNumber
+  , severity
+  , sourceAddress
+  , sourceCountry
+  , sourcePort
+  , sourceUser
+  , sourceZone
+  , subject
+  , subtype
+  , threatCategory
+  , threatId
+  , threatName
+  , timeGenerated
+  , virtualSystemName
+  ) where
+
+import Data.Bytes.Types (Bytes(..))
+import Panos.Syslog.Unsafe (Threat(Threat),Bounds(Bounds))
+import Data.Word (Word64,Word16)
+import Chronos (Datetime)
+import Net.Types (IP)
+import qualified Panos.Syslog.Unsafe as U
+
+-- | Subtype of threat log. Values include: @data@, @file@, @flood@,
+-- @packet@, @scan@, @spyware@, @url@, @virus@, @vulnerability@,
+-- @wildfire@, @wildfire-virus@.
+subtype :: Threat -> Bytes
+subtype (Threat{subtype=Bounds off len,message=msg}) =
+  Bytes{offset=fromIntegral off,length=fromIntegral len,array=msg}
+
+-- | Username of the user who initiated the session.
+sourceUser :: Threat -> Bytes
+sourceUser (Threat{sourceUser=Bounds off len,message=msg}) =
+  Bytes{offset=fromIntegral off,length=fromIntegral len,array=msg}
+
+-- | Username of the user to which the session was destined.
+destinationUser :: Threat -> Bytes
+destinationUser (Threat{destinationUser=Bounds off len,message=msg}) =
+  Bytes{offset=fromIntegral off,length=fromIntegral len,array=msg}
+
+-- | Interface that the session was sourced from.
+inboundInterface :: Threat -> Bytes
+inboundInterface (Threat{inboundInterface=Bounds off len,message=msg}) =
+  Bytes{offset=fromIntegral off,length=fromIntegral len,array=msg}
+
+-- | Interface that the session was destined to.
+outboundInterface :: Threat -> Bytes
+outboundInterface (Threat{outboundInterface=Bounds off len,message=msg}) =
+  Bytes{offset=fromIntegral off,length=fromIntegral len,array=msg}
+
+-- | Zone the session was sourced from.
+sourceZone :: Threat -> Bytes
+sourceZone (Threat{sourceZone=Bounds off len,message=msg}) =
+  Bytes{offset=fromIntegral off,length=fromIntegral len,array=msg}
+
+-- | Zone the session was destined to.
+destinationZone :: Threat -> Bytes
+destinationZone (Threat{destinationZone=Bounds off len,message=msg}) =
+  Bytes{offset=fromIntegral off,length=fromIntegral len,array=msg}
+
+-- | The hostname of the firewall on which the session was logged.
+deviceName :: Threat -> Bytes
+deviceName (Threat{deviceName=Bounds off len,message=msg}) =
+  Bytes{offset=fromIntegral off,length=fromIntegral len,array=msg}
+
+-- | Palo Alto Networks identifier for the threat. It is a description
+-- string followed by a 64-bit numerical identifier in parentheses for
+-- some subtypes.
+--
+-- This field is just the description string. The numerical identifier
+-- goes can be accessed with 'threatId'.
+threatName :: Threat -> Bytes
+threatName (Threat{threatName=Bounds off len,message=msg}) =
+  Bytes{offset=fromIntegral off,length=fromIntegral len,array=msg}
+
+-- | The numerical identifier for a threat. See 'threatName'.
+threatId :: Threat -> Word64
+threatId = U.threatId
+
+-- | Time the log was generated on the dataplane.
+timeGenerated :: Threat -> Datetime
+timeGenerated = U.timeGenerated
+
+-- | For URL Subtype, it is the URL Category; For WildFire subtype,
+-- it is the verdict on the file and is either @malicious@, @grayware@,
+-- or @benign@; For other subtypes, the value is @any@.
+category :: Threat -> Bytes
+category (Threat{category=Bounds off len,message=msg}) =
+  Bytes{offset=fromIntegral off,length=fromIntegral len,array=msg}
+
+contentVersion :: Threat -> Bytes
+contentVersion (Threat{contentVersion=Bounds off len,message=msg}) =
+  Bytes{offset=fromIntegral off,length=fromIntegral len,array=msg}
+
+-- | The name of the virtual system associated with the session;
+-- only valid on firewalls enabled for multiple virtual systems.
+virtualSystemName :: Threat -> Bytes
+virtualSystemName (Threat{virtualSystemName=Bounds off len,message=msg}) =
+  Bytes{offset=fromIntegral off,length=fromIntegral len,array=msg}
+
+threatCategory :: Threat -> Bytes
+threatCategory (Threat{threatCategory=Bounds off len,message=msg}) =
+  Bytes{offset=fromIntegral off,length=fromIntegral len,array=msg}
+
+httpHeaders :: Threat -> Bytes
+httpHeaders = U.httpHeaders
+
+miscellaneous :: Threat -> Bytes
+miscellaneous (Threat{miscellaneousBounds=Bounds off len,miscellaneousByteArray=m}) =
+  Bytes{offset=fromIntegral off,length=fromIntegral len,array=m}
+
+-- | A 64-bit log entry identifier incremented sequentially. Each log
+-- type has a unique number space. This field is not supported on
+-- PA-7000 Series firewalls.
+sequenceNumber :: Threat -> Word64
+sequenceNumber = U.sequenceNumber
+
+-- | Serial number of the firewall that generated the log. These
+-- occassionally contain non-numeric characters, so do not attempt
+-- to parse this as a decimal number.
+serialNumber :: Threat -> Bytes
+serialNumber (Threat{serialNumber=Bounds off len,message=msg}) =
+  Bytes{offset=off,length=len,array=msg}
+
+-- | Severity associated with the threat; values are informational,
+-- low, medium, high, critical.
+severity :: Threat -> Bytes
+severity (Threat{severity=Bounds off len,message=msg}) =
+  Bytes{offset=fromIntegral off,length=fromIntegral len,array=msg}
+
+referer :: Threat -> Bytes
+referer = U.referer
+
+httpMethod :: Threat -> Bytes
+httpMethod (Threat{httpMethod=Bounds off len,message=msg}) =
+  Bytes{offset=fromIntegral off,length=fromIntegral len,array=msg}
+
+-- | Action taken for the session; values are @alert@, @allow@,
+-- @deny@, @drop@, @drop-all-packets@, @reset-client@, @reset-server@,
+-- @reset-both@, @block-url@.
+action :: Threat -> Bytes
+action (Threat{action=Bounds off len,message=msg}) =
+  Bytes{offset=fromIntegral off,length=fromIntegral len,array=msg}
+
+-- | Application associated with the session.
+application :: Threat -> Bytes
+application (Threat{application=Bounds off len,message=msg}) =
+  Bytes{offset=fromIntegral off,length=fromIntegral len,array=msg}
+
+-- | Name of the rule that the session matched.
+ruleName :: Threat -> Bytes
+ruleName (Threat{ruleName=Bounds off len,message=msg}) =
+  Bytes{offset=fromIntegral off,length=fromIntegral len,array=msg}
+
+-- | Original session destination IP address.
+destinationAddress :: Threat -> IP
+destinationAddress = U.destinationAddress
+
+-- | Original session source IP address.
+sourceAddress :: Threat -> IP
+sourceAddress = U.sourceAddress
+
+-- | Source port utilized by the session.
+sourcePort :: Threat -> Word16
+sourcePort = U.sourcePort
+
+-- | Destination port utilized by the session.
+destinationPort :: Threat -> Word16
+destinationPort = U.destinationPort
+
+sender :: Threat -> Bytes
+sender = U.sender
+
+subject :: Threat -> Bytes
+subject = U.subject
+
+recipient :: Threat -> Bytes
+recipient = U.recipient
+
+-- | Post-NAT destination port.
+natDestinationPort :: Threat -> Word16
+natDestinationPort = U.natDestinationPort
+
+-- | If Source NAT performed, the post-NAT Source IP address.
+natSourceIp :: Threat -> IP
+natSourceIp = U.natSourceIp
+
+-- | If Destination NAT performed, the post-NAT Destination IP address.
+natDestinationIp :: Threat -> IP
+natDestinationIp = U.natDestinationIp
+
+-- | Post-NAT source port.
+natSourcePort :: Threat -> Word16
+natSourcePort = U.natSourcePort
+
+-- | Source country or Internal region for private addresses;
+-- maximum length is 32 bytes.
+sourceCountry :: Threat -> Bytes
+sourceCountry (Threat{sourceCountry=Bounds off len,message=msg}) =
+  Bytes{offset=off,length=len,array=msg}
+
+-- | Destination country or Internal region for private addresses.
+-- Maximum length is 32 bytes.
+destinationCountry :: Threat -> Bytes
+destinationCountry (Threat{destinationCountry=Bounds off len,message=msg}) =
+  Bytes{offset=off,length=len,array=msg}
diff --git a/src/Panos/Syslog/Traffic.hs b/src/Panos/Syslog/Traffic.hs
new file mode 100644
--- /dev/null
+++ b/src/Panos/Syslog/Traffic.hs
@@ -0,0 +1,267 @@
+{-# language BangPatterns #-}
+{-# language MagicHash #-}
+{-# language NamedFieldPuns #-}
+{-# language DuplicateRecordFields #-}
+{-# language NumericUnderscores #-}
+{-# language DerivingStrategies #-}
+{-# language GeneralizedNewtypeDeriving #-}
+
+-- | Fields for traffic logs.
+module Panos.Syslog.Traffic
+  ( -- * Fields
+    action
+  , application
+  , bytes
+  , bytesReceived
+  , bytesSent
+  , destinationAddress
+  , destinationCountry
+  , destinationPort
+  , destinationUser
+  , destinationZone
+  , deviceName
+  , elapsedTime
+  , inboundInterface
+  , ipProtocol
+  , logAction
+  , natDestinationIp
+  , natDestinationPort
+  , natSourceIp
+  , natSourcePort
+  , outboundInterface
+  , packets
+  , packetsReceived
+  , packetsSent
+  , ruleName
+  , sequenceNumber
+  , serialNumber
+  , sessionEndReason
+  , sourceAddress
+  , sourceCountry
+  , sourcePort
+  , sourceUser
+  , sourceZone
+  , subtype
+  , syslogHost
+  , timeGenerated
+  , virtualSystem
+  , virtualSystemName
+  -- * Device Group Hierarchy
+  , deviceGroupHierarchyLevel1
+  , deviceGroupHierarchyLevel2
+  , deviceGroupHierarchyLevel3
+  , deviceGroupHierarchyLevel4
+  ) where
+
+import Chronos (Datetime)
+import Data.Bytes.Types (Bytes(..))
+import Panos.Syslog.Unsafe (Traffic(Traffic),Bounds(Bounds))
+import Data.Word (Word64,Word16)
+import Net.Types (IP)
+import qualified Panos.Syslog.Unsafe as U
+
+-- | IP protocol associated with the session.
+ipProtocol :: Traffic -> Bytes
+ipProtocol (Traffic{ipProtocol=Bounds off len,message=msg}) =
+  Bytes{offset=off,length=len,array=msg}
+
+-- | The hostname from the syslog header appended to the PAN-OS log.
+-- This field is not documented by Palo Alto Network and technically
+-- is not part of the log, but in practice, it is always present.
+-- This is similar to @deviceName@.
+syslogHost :: Traffic -> Bytes
+syslogHost (Traffic{syslogHost=Bounds off len,message=msg}) =
+  Bytes{offset=off,length=len,array=msg}
+
+-- | Subtype of traffic log; values are @start@, @end@, @drop@, and @deny@.
+--
+-- * Start: session started
+-- * End: session ended
+-- * Drop: session dropped before the application is identified and
+--   there is no rule that allows the session.
+-- * Deny: session dropped after the application is identified and
+--   there is a rule to block or no rule that allows the session.
+subtype :: Traffic -> Bytes
+subtype (Traffic{subtype=Bounds off len,message=msg}) =
+  Bytes{offset=off,length=len,array=msg}
+
+-- | Name of the rule that the session matched.
+ruleName :: Traffic -> Bytes
+ruleName (Traffic{ruleName=Bounds off len,message=msg}) =
+  Bytes{offset=off,length=len,array=msg}
+
+-- | Interface that the session was sourced from.
+inboundInterface :: Traffic -> Bytes
+inboundInterface (Traffic{inboundInterface=Bounds off len,message=msg}) =
+  Bytes{offset=off,length=len,array=msg}
+
+-- | Interface that the session was destined to.
+outboundInterface :: Traffic -> Bytes
+outboundInterface (Traffic{outboundInterface=Bounds off len,message=msg}) =
+  Bytes{offset=off,length=len,array=msg}
+
+-- | Log Forwarding Profile that was applied to the session.
+logAction :: Traffic -> Bytes
+logAction (Traffic{logAction=Bounds off len,message=msg}) =
+  Bytes{offset=off,length=len,array=msg}
+
+-- | Action taken for the session; possible values are:
+--
+-- * allow: session was allowed by policy
+-- * deny: session was denied by policy
+-- * drop: session was dropped silently
+-- * drop ICMP: session was silently dropped with an ICMP unreachable
+--   message to the host or application
+-- * reset both: session was terminated and a TCP reset is sent to
+--   both the sides of the connection
+-- * reset client: session was terminated and a TCP reset is sent to the client
+-- * reset server: session was terminated and a TCP reset is sent to the server
+action :: Traffic -> Bytes
+action (Traffic{action=Bounds off len,message=msg}) =
+  Bytes{offset=off,length=len,array=msg}
+
+-- | Application associated with the session.
+application :: Traffic -> Bytes
+application (Traffic{application=Bounds off len,message=msg}) =
+  Bytes{offset=off,length=len,array=msg}
+
+-- | The hostname of the firewall on which the session was logged.
+deviceName :: Traffic -> Bytes
+deviceName (Traffic{deviceName=Bounds off len,message=msg}) =
+  Bytes{offset=off,length=len,array=msg}
+
+-- | Elapsed time of the session.
+elapsedTime :: Traffic -> Word64
+elapsedTime = U.elapsedTime
+
+-- | Username of the user who initiated the session.
+sourceUser :: Traffic -> Bytes
+sourceUser (Traffic{sourceUser=Bounds off len,message=msg}) =
+  Bytes{offset=off,length=len,array=msg}
+
+-- | Username of the user to which the session was destined.
+destinationUser :: Traffic -> Bytes
+destinationUser (Traffic{destinationUser=Bounds off len,message=msg}) =
+  Bytes{offset=off,length=len,array=msg}
+
+-- | Number of server-to-client packets for the session.
+packetsReceived :: Traffic -> Word64
+packetsReceived = U.packetsReceived
+
+-- | Number of client-to-server packets for the session.
+packetsSent :: Traffic -> Word64
+packetsSent = U.packetsSent
+
+-- | Source port utilized by the session.
+sourcePort :: Traffic -> Word16
+sourcePort = U.sourcePort
+
+-- | Time the log was generated on the dataplane.
+timeGenerated :: Traffic -> Datetime
+timeGenerated = U.timeGenerated
+
+-- | A 64-bit log entry identifier incremented sequentially;
+-- each log type has a unique number space.
+sequenceNumber :: Traffic -> Word64
+sequenceNumber = U.sequenceNumber
+
+-- | Post-NAT source port.
+natSourcePort :: Traffic -> Word16
+natSourcePort = U.natSourcePort
+
+-- | Destination port utilized by the session.
+destinationPort :: Traffic -> Word16
+destinationPort = U.destinationPort
+
+-- | Post-NAT destination port.
+natDestinationPort :: Traffic -> Word16
+natDestinationPort = U.natDestinationPort
+
+-- | If Source NAT performed, the post-NAT Source IP address.
+natSourceIp :: Traffic -> IP
+natSourceIp = U.natSourceIp
+
+-- | If Destination NAT performed, the post-NAT Destination IP address.
+natDestinationIp :: Traffic -> IP
+natDestinationIp = U.natDestinationIp
+
+-- | Original session source IP address.
+sourceAddress :: Traffic -> IP
+sourceAddress = U.sourceAddress
+
+-- | Original session destination IP address.
+destinationAddress :: Traffic -> IP
+destinationAddress = U.destinationAddress
+
+-- | Number of total packets (transmit and receive) for the session.
+packets :: Traffic -> Word64
+packets = U.packets
+
+-- | Number of bytes in the server-to-client direction of the session.
+bytesReceived :: Traffic -> Word64
+bytesReceived = U.bytesReceived
+
+-- | Number of bytes in the client-to-server direction of the session.
+bytesSent :: Traffic -> Word64
+bytesSent = U.bytesSent
+
+-- | Number of total bytes (transmit and receive) for the session.
+bytes :: Traffic -> Word64
+bytes = U.bytes
+
+-- | Serial number of the firewall that generated the log. These
+-- occassionally contain non-numeric characters, so do not attempt
+-- to parse this as a decimal number.
+serialNumber :: Traffic -> Bytes
+serialNumber (Traffic{serialNumber=Bounds off len,message=msg}) =
+  Bytes{offset=off,length=len,array=msg}
+
+deviceGroupHierarchyLevel1 :: Traffic -> Word64
+deviceGroupHierarchyLevel1 = U.deviceGroupHierarchyLevel1
+
+deviceGroupHierarchyLevel2 :: Traffic -> Word64
+deviceGroupHierarchyLevel2 = U.deviceGroupHierarchyLevel2
+
+deviceGroupHierarchyLevel3 :: Traffic -> Word64
+deviceGroupHierarchyLevel3 = U.deviceGroupHierarchyLevel3
+
+deviceGroupHierarchyLevel4 :: Traffic -> Word64
+deviceGroupHierarchyLevel4 = U.deviceGroupHierarchyLevel4
+
+-- | The reason a session terminated.
+sessionEndReason :: Traffic -> Bytes
+sessionEndReason (Traffic{sessionEndReason=Bounds off len,message=msg}) =
+  Bytes{offset=off,length=len,array=msg}
+
+-- | Virtual System associated with the session.
+virtualSystem :: Traffic -> Bytes
+virtualSystem (Traffic{virtualSystem=Bounds off len,message=msg}) =
+  Bytes{offset=off,length=len,array=msg}
+
+-- | The name of the virtual system associated with the session; only valid
+-- on firewalls enabled for multiple virtual systems.
+virtualSystemName :: Traffic -> Bytes
+virtualSystemName (Traffic{virtualSystemName=Bounds off len,message=msg}) =
+  Bytes{offset=off,length=len,array=msg}
+
+-- | Zone the session was sourced from.
+sourceZone :: Traffic -> Bytes
+sourceZone (Traffic{sourceZone=Bounds off len,message=msg}) =
+  Bytes{offset=off,length=len,array=msg}
+
+-- | Zone the session was destined to.
+destinationZone :: Traffic -> Bytes
+destinationZone (Traffic{destinationZone=Bounds off len,message=msg}) =
+  Bytes{offset=off,length=len,array=msg}
+
+-- | Source country or Internal region for private addresses;
+-- maximum length is 32 bytes.
+sourceCountry :: Traffic -> Bytes
+sourceCountry (Traffic{sourceCountry=Bounds off len,message=msg}) =
+  Bytes{offset=off,length=len,array=msg}
+
+-- | Destination country or Internal region for private addresses.
+-- Maximum length is 32 bytes.
+destinationCountry :: Traffic -> Bytes
+destinationCountry (Traffic{destinationCountry=Bounds off len,message=msg}) =
+  Bytes{offset=off,length=len,array=msg}
diff --git a/src/Panos/Syslog/Unsafe.hs b/src/Panos/Syslog/Unsafe.hs
new file mode 100644
--- /dev/null
+++ b/src/Panos/Syslog/Unsafe.hs
@@ -0,0 +1,1245 @@
+{-# language BangPatterns #-}
+{-# language DerivingStrategies #-}
+{-# language DuplicateRecordFields #-}
+{-# language GeneralizedNewtypeDeriving #-}
+{-# language LambdaCase #-}
+{-# language MagicHash #-}
+{-# language NamedFieldPuns #-}
+{-# language NumericUnderscores #-}
+{-# language ScopedTypeVariables #-}
+
+module Panos.Syslog.Unsafe
+  ( -- * Types
+    Log(..)
+  , Traffic(..)
+  , Threat(..)
+  , System(..)
+  , Field(..)
+  , Bounds(..)
+    -- * Decoding
+  , decode
+  ) where
+
+import Chronos (DayOfMonth(..),Date(..))
+import Chronos (Year(..),Month(..),Datetime(..),TimeOfDay(..))
+import Control.Exception (Exception)
+import Control.Monad.ST.Run (runByteArrayST)
+import Data.Bytes.Parser (Parser)
+import Data.Bytes.Types (Bytes(..),UnmanagedBytes(UnmanagedBytes))
+import Data.Char (ord,isAsciiUpper,isAsciiLower)
+import Data.Primitive (ByteArray)
+import Data.Primitive.Addr (Addr(Addr))
+import Data.Word (Word64,Word32,Word16,Word8)
+import GHC.Exts (Ptr(Ptr),Int(I#),Int#,Addr#)
+import Net.Types (IP)
+
+import qualified Control.Exception
+import qualified Data.Bytes.Parser as P
+import qualified Data.Bytes.Parser.Ascii as Ascii
+import qualified Data.Bytes.Parser.Latin as Latin
+import qualified Data.Bytes.Parser.Unsafe as Unsafe
+import qualified Data.Primitive as PM
+import qualified Data.Primitive.Ptr as PM
+import qualified GHC.Exts as Exts
+import qualified GHC.Pack
+import qualified Net.IP as IP
+import qualified Net.IPv4 as IPv4
+
+-- | Sum that represents all known PAN-OS syslog types. Use 'decode'
+-- to parse a byte sequence into a structured log.
+data Log
+  = LogTraffic !Traffic
+  | LogThreat !Threat
+  | LogSystem !System
+  | LogOther
+
+data Bounds = Bounds
+  {-# UNPACK #-} !Int -- offset
+  {-# UNPACK #-} !Int -- length
+
+-- | A PAN-OS system log. Read-only accessors are found in
+-- @Panos.Syslog.System@.
+data System = System
+  { message :: {-# UNPACK #-} !ByteArray
+    -- The original log
+  , syslogHost :: {-# UNPACK #-} !Bounds
+    -- The host as presented in the syslog preamble that
+    -- prefixes the message.
+  , receiveTime :: {-# UNPACK #-} !Datetime
+    -- In log, presented as: 2019/06/18 15:10:20
+  , serialNumber :: {-# UNPACK #-} !Bounds
+    -- In log, presented as: 002610378847
+  , subtype :: {-# UNPACK #-} !Bounds
+    -- Presented as: dhcp, dnsproxy, dos, general, etc.
+  , timeGenerated :: {-# UNPACK #-} !Datetime
+    -- Presented as: 2019/11/04 08:39:05
+  , virtualSystem :: {-# UNPACK #-} !Bounds
+  , eventId :: {-# UNPACK #-} !Bounds
+  , object :: {-# UNPACK #-} !Bounds
+  , module_ :: {-# UNPACK #-} !Bounds
+  , severity :: {-# UNPACK #-} !Bounds
+  , descriptionBounds :: {-# UNPACK #-} !Bounds
+  , descriptionByteArray :: {-# UNPACK #-} !ByteArray
+  , sequenceNumber :: {-# UNPACK #-} !Word64
+  , actionFlags :: {-# UNPACK #-} !Word64
+  , deviceGroupHierarchyLevel1 :: {-# UNPACK #-} !Word64
+  , deviceGroupHierarchyLevel2 :: {-# UNPACK #-} !Word64
+  , deviceGroupHierarchyLevel3 :: {-# UNPACK #-} !Word64
+  , deviceGroupHierarchyLevel4 :: {-# UNPACK #-} !Word64
+  , virtualSystemName :: {-# UNPACK #-} !Bounds
+  , deviceName :: {-# UNPACK #-} !Bounds
+  }
+
+-- | A PAN-OS traffic log. Read-only accessors are found in
+-- @Panos.Syslog.Traffic@.
+data Traffic = Traffic
+  { message :: {-# UNPACK #-} !ByteArray
+    -- The original log
+  , syslogHost :: {-# UNPACK #-} !Bounds
+    -- The host as presented in the syslog preamble that
+    -- prefixes the message.
+  , receiveTime :: {-# UNPACK #-} !Datetime
+    -- In log, presented as: 2019/06/18 15:10:20
+  , serialNumber :: {-# UNPACK #-} !Bounds
+    -- In log, presented as: 002610378847
+  , subtype :: {-# UNPACK #-} !Bounds
+    -- Presented as: start, end, drop, and deny
+  , timeGenerated :: {-# UNPACK #-} !Datetime
+    -- Presented as: 2018/04/11 23:19:22
+  , sourceAddress :: {-# UNPACK #-} !IP
+  , destinationAddress :: {-# UNPACK #-} !IP
+  , natSourceIp :: {-# UNPACK #-} !IP
+  , natDestinationIp :: {-# UNPACK #-} !IP
+  , ruleName :: {-# UNPACK #-} !Bounds
+  , sourceUser :: {-# UNPACK #-} !Bounds
+  , destinationUser :: {-# UNPACK #-} !Bounds
+  , application :: {-# UNPACK #-} !Bounds
+  , virtualSystem :: {-# UNPACK #-} !Bounds
+  , sourceZone :: {-# UNPACK #-} !Bounds
+  , destinationZone :: {-# UNPACK #-} !Bounds
+  , inboundInterface :: {-# UNPACK #-} !Bounds
+  , outboundInterface :: {-# UNPACK #-} !Bounds
+  , logAction :: {-# UNPACK #-} !Bounds
+  , sessionId :: {-# UNPACK #-} !Word64
+  , repeatCount :: {-# UNPACK #-} !Word64
+  , sourcePort :: {-# UNPACK #-} !Word16
+  , destinationPort :: {-# UNPACK #-} !Word16
+  , natSourcePort :: {-# UNPACK #-} !Word16
+  , natDestinationPort :: {-# UNPACK #-} !Word16
+  , flags :: {-# UNPACK #-} !Word32
+    -- Presented as: 0x400053
+  , ipProtocol :: {-# UNPACK #-} !Bounds
+    -- Presented as: tcp, udp, etc.
+  , action :: {-# UNPACK #-} !Bounds
+  , bytes :: {-# UNPACK #-} !Word64
+  , bytesSent :: {-# UNPACK #-} !Word64
+  , bytesReceived :: {-# UNPACK #-} !Word64
+  , packets :: {-# UNPACK #-} !Word64
+  , startTime :: {-# UNPACK #-} !Datetime
+  , elapsedTime :: {-# UNPACK #-} !Word64
+  , category :: {-# UNPACK #-} !Bounds
+  , sequenceNumber :: {-# UNPACK #-} !Word64
+  , actionFlags :: {-# UNPACK #-} !Word64
+    -- Presented as: 0x8000000000000000
+  , sourceCountry :: {-# UNPACK #-} !Bounds
+  , destinationCountry :: {-# UNPACK #-} !Bounds
+  , packetsSent :: {-# UNPACK #-} !Word64
+  , packetsReceived :: {-# UNPACK #-} !Word64
+  , sessionEndReason :: {-# UNPACK #-} !Bounds
+  , deviceGroupHierarchyLevel1 :: {-# UNPACK #-} !Word64
+  , deviceGroupHierarchyLevel2 :: {-# UNPACK #-} !Word64
+  , deviceGroupHierarchyLevel3 :: {-# UNPACK #-} !Word64
+  , deviceGroupHierarchyLevel4 :: {-# UNPACK #-} !Word64
+  , virtualSystemName :: {-# UNPACK #-} !Bounds
+  , deviceName :: {-# UNPACK #-} !Bounds
+  , actionSource :: {-# UNPACK #-} !Bounds
+  }
+
+-- | A PAN-OS threat log. Read-only accessors are found in
+-- @Panos.Syslog.Threat@.
+data Threat = Threat
+  { message :: {-# UNPACK #-} !ByteArray
+    -- The original log
+  , syslogHost :: {-# UNPACK #-} !Bounds
+    -- The host as presented in the syslog preamble that
+    -- prefixes the message.
+  , receiveTime :: {-# UNPACK #-} !Datetime
+    -- In log, presented as: 2019/06/18 15:10:20
+  , serialNumber :: {-# UNPACK #-} !Bounds
+    -- In log, presented as: 002610378847
+  , subtype :: {-# UNPACK #-} !Bounds
+    -- Presented as: data, file, flood, packet, scan, spyware, url,
+    -- virus, vulnerability, wildfire, or wildfire-virus.
+  , timeGenerated :: {-# UNPACK #-} !Datetime
+    -- Presented as: 2018/04/11 23:19:22
+  , sourceAddress :: {-# UNPACK #-} !IP
+  , destinationAddress :: {-# UNPACK #-} !IP
+  , natSourceIp :: {-# UNPACK #-} !IP
+  , natDestinationIp :: {-# UNPACK #-} !IP
+  , ruleName :: {-# UNPACK #-} !Bounds
+  , sourceUser :: {-# UNPACK #-} !Bounds
+  , destinationUser :: {-# UNPACK #-} !Bounds
+  , application :: {-# UNPACK #-} !Bounds
+  , virtualSystem :: {-# UNPACK #-} !Bounds
+  , sourceZone :: {-# UNPACK #-} !Bounds
+  , destinationZone :: {-# UNPACK #-} !Bounds
+  , inboundInterface :: {-# UNPACK #-} !Bounds
+  , outboundInterface :: {-# UNPACK #-} !Bounds
+  , logAction :: {-# UNPACK #-} !Bounds
+  , sessionId :: {-# UNPACK #-} !Word64
+  , repeatCount :: {-# UNPACK #-} !Word64
+  , sourcePort :: {-# UNPACK #-} !Word16
+  , destinationPort :: {-# UNPACK #-} !Word16
+  , natSourcePort :: {-# UNPACK #-} !Word16
+  , natDestinationPort :: {-# UNPACK #-} !Word16
+  , action :: {-# UNPACK #-} !Bounds
+  , ipProtocol :: {-# UNPACK #-} !Bounds
+  , flags :: {-# UNPACK #-} !Word32
+  , miscellaneousBounds :: {-# UNPACK #-} !Bounds
+  , miscellaneousByteArray :: {-# UNPACK #-} !ByteArray
+  , threatName :: {-# UNPACK #-} !Bounds
+  , threatId :: {-# UNPACK #-} !Word64
+  , category :: {-# UNPACK #-} !Bounds
+  , severity :: {-# UNPACK #-} !Bounds
+  , direction :: {-# UNPACK #-} !Bounds
+  , sequenceNumber :: {-# UNPACK #-} !Word64
+  , actionFlags :: {-# UNPACK #-} !Word64
+    -- Presented as: 0x8000000000000000
+  , sourceCountry :: {-# UNPACK #-} !Bounds
+  , destinationCountry :: {-# UNPACK #-} !Bounds
+  , contentType :: {-# UNPACK #-} !Bounds
+  , pcapId :: {-# UNPACK #-} !Word64
+  , fileDigest :: {-# UNPACK #-} !Bounds
+    -- Only used by wildfire subtype
+    -- TODO: make the file digest a 128-bit or 256-bit word
+  , cloud :: {-# UNPACK #-} !Bounds
+    -- Only used by wildfire subtype
+  , urlIndex :: {-# UNPACK #-} !Word64
+    -- Only used by wildfire subtype
+  , userAgentBounds :: {-# UNPACK #-} !Bounds
+  , userAgentByteArray :: {-# UNPACK #-} !ByteArray
+    -- Only used by url filtering subtype. This field may have
+    -- escaped characters, so we include the possibility of
+    -- using a byte array distinct from the original log.
+  , fileType :: {-# UNPACK #-} !Bounds
+    -- Only used by wildfire subtype
+  , forwardedFor :: {-# UNPACK #-} !Bounds
+    -- Only used by url filtering subtype
+  , referer :: {-# UNPACK #-} !Bytes
+    -- Only used by url filtering subtype
+  , sender :: {-# UNPACK #-} !Bytes
+    -- Only used by wildfire subtype
+  , subject :: {-# UNPACK #-} !Bytes
+    -- Only used by wildfire subtype
+  , recipient :: {-# UNPACK #-} !Bytes
+    -- Only used by wildfire subtype
+  , reportId :: {-# UNPACK #-} !Bounds
+    -- Only used by wildfire subtype
+  , deviceGroupHierarchyLevel1 :: {-# UNPACK #-} !Word64
+  , deviceGroupHierarchyLevel2 :: {-# UNPACK #-} !Word64
+  , deviceGroupHierarchyLevel3 :: {-# UNPACK #-} !Word64
+  , deviceGroupHierarchyLevel4 :: {-# UNPACK #-} !Word64
+  , virtualSystemName :: {-# UNPACK #-} !Bounds
+  , deviceName :: {-# UNPACK #-} !Bounds
+    -- TODO: skipping over uuid fields for now
+  , httpMethod :: {-# UNPACK #-} !Bounds
+  , tunnelId :: {-# UNPACK #-} !Word64
+  , parentSessionId :: {-# UNPACK #-} !Word64
+    -- Only used by url subtype
+  , threatCategory :: {-# UNPACK #-} !Bounds
+  , contentVersion :: {-# UNPACK #-} !Bounds
+    -- TODO: skipping some fields here
+  , sctpAssociationId :: {-# UNPACK #-} !Word64
+  , payloadProtocolId :: {-# UNPACK #-} !Word64
+    -- TODO: skipping over other fields here
+  , httpHeaders :: {-# UNPACK #-} !Bytes
+  }
+
+-- | The field that was being parsed when a parse failure occurred.
+-- This is typically for useful for libary developers, but to present
+-- it to the end user, call @show@ or @throwIO@.
+newtype Field = Field UnmanagedBytes
+
+instance Show Field where
+  showsPrec _ (Field (UnmanagedBytes (Addr addr) _)) s =
+    '"' : GHC.Pack.unpackAppendCString# addr ('"' : s)
+
+instance Exception Field where
+  displayException (Field (UnmanagedBytes (Addr addr) _)) =
+    GHC.Pack.unpackCString# addr
+
+syslogPriorityField :: Field
+syslogPriorityField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "syslogPriority"#
+
+syslogHostField :: Field
+syslogHostField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "syslogHost"#
+
+syslogDatetimeField :: Field
+syslogDatetimeField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "syslogDatetime"#
+
+receiveTimeDateField :: Field
+receiveTimeDateField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "receiveTime:date"#
+
+receiveTimeTimeField :: Field
+receiveTimeTimeField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "receiveTime:time"#
+
+serialNumberField :: Field
+serialNumberField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "serialNumber"#
+
+typeField :: Field
+typeField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "type"#
+
+subtypeField :: Field
+subtypeField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "subtype"#
+
+timeGeneratedDateField :: Field
+timeGeneratedDateField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "timeGenerated:date"#
+
+timeGeneratedTimeField :: Field
+timeGeneratedTimeField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "timeGenerated:time"#
+
+sourceAddressField :: Field
+sourceAddressField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "sourceAddress"#
+
+destinationAddressField :: Field
+destinationAddressField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "destinationAddress"#
+
+natSourceIpField :: Field
+natSourceIpField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "natSourceIp"#
+
+natDestinationIpField :: Field
+natDestinationIpField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "natDestinationIp"#
+
+ruleNameField :: Field
+ruleNameField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "ruleName"#
+
+sourceUserField :: Field
+sourceUserField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "sourceUser"#
+
+destinationUserField :: Field
+destinationUserField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "destinationUser"#
+
+applicationField :: Field
+applicationField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "application"#
+
+virtualSystemField :: Field
+virtualSystemField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "virtualSystem"#
+
+sourceZoneField :: Field
+sourceZoneField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "sourceZone"#
+
+destinationZoneField :: Field
+destinationZoneField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "destinationZone"#
+
+inboundInterfaceField :: Field
+inboundInterfaceField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "inboundInterface"#
+
+outboundInterfaceField :: Field
+outboundInterfaceField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "outboundInterface"#
+
+logActionField :: Field
+logActionField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "logAction"#
+
+sessionIdField :: Field
+sessionIdField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "sessionId"#
+
+repeatCountField :: Field
+repeatCountField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "repeatCount"#
+
+sourcePortField :: Field
+sourcePortField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "sourcePort"#
+
+destinationPortField :: Field
+destinationPortField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "destinationPort"#
+
+natSourcePortField :: Field
+natSourcePortField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "natSourcePort"#
+
+natDestinationPortField :: Field
+natDestinationPortField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "natDestinationPort"#
+
+flagsField :: Field
+flagsField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "flags"#
+
+ipProtocolField :: Field
+ipProtocolField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "ipProtocol"#
+
+actionField :: Field
+actionField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "action"#
+
+bytesField :: Field
+bytesField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "bytes"#
+
+bytesSentField :: Field
+bytesSentField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "bytesSent"#
+
+bytesReceivedField :: Field
+bytesReceivedField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "bytesReceived"#
+
+packetsField :: Field
+packetsField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "packets"#
+
+startTimeDateField :: Field
+startTimeDateField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "startTime:date"#
+
+startTimeTimeField :: Field
+startTimeTimeField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "startTime:time"#
+
+elapsedTimeField :: Field
+elapsedTimeField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "elapsedTime"#
+
+categoryField :: Field
+categoryField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "category"#
+
+sequenceNumberField :: Field
+sequenceNumberField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "sequenceNumber"#
+
+actionFlagsField :: Field
+actionFlagsField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "actionFlags"#
+
+sourceCountryField :: Field
+sourceCountryField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "sourceCountry"#
+
+destinationCountryField :: Field
+destinationCountryField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "destinationCountry"#
+
+packetsSentField :: Field
+packetsSentField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "packetsSent"#
+
+packetsReceivedField :: Field
+packetsReceivedField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "packetsReceived"#
+
+sessionEndReasonField :: Field
+sessionEndReasonField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "sessionEndReason"#
+
+deviceGroupHierarchyLevel1Field :: Field
+deviceGroupHierarchyLevel1Field = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "deviceGroupHierarchyLevel1"#
+
+deviceGroupHierarchyLevel2Field :: Field
+deviceGroupHierarchyLevel2Field = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "deviceGroupHierarchyLevel2"#
+
+deviceGroupHierarchyLevel3Field :: Field
+deviceGroupHierarchyLevel3Field = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "deviceGroupHierarchyLevel3"#
+
+deviceGroupHierarchyLevel4Field :: Field
+deviceGroupHierarchyLevel4Field = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "deviceGroupHierarchyLevel4"#
+
+virtualSystemNameField :: Field
+virtualSystemNameField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "virtualSystemName"#
+
+payloadProtocolField :: Field
+payloadProtocolField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "field:payloadProtocol"#
+
+senderField :: Field
+senderField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "field:sender"#
+
+recipientField :: Field
+recipientField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "field:recipient"#
+
+refererField :: Field
+refererField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "field:referer"#
+
+pcapIdField :: Field
+pcapIdField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "field:pcapId"#
+
+directionField :: Field
+directionField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "field:direction"#
+
+contentTypeField :: Field
+contentTypeField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "field:contentType"#
+
+severityField :: Field
+severityField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "field:severity"#
+
+cloudField :: Field
+cloudField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "field:cloud"#
+
+threatCategoryField :: Field
+threatCategoryField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "field:threatCategory"#
+
+urlIndexField :: Field
+urlIndexField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "field:urlIndex"#
+
+fileDigestField :: Field
+fileDigestField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "field:fileDigest"#
+
+fileTypeField :: Field
+fileTypeField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "field:fileType"#
+
+forwardedForField :: Field
+forwardedForField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "field:forwardedFor"#
+
+userAgentField :: Field
+userAgentField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "field:userAgent"#
+
+subjectField :: Field
+subjectField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "field:subject"#
+
+contentVersionField :: Field
+contentVersionField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "field:contentVersion"#
+
+httpMethodField :: Field
+httpMethodField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "field:httpMethod"#
+
+httpHeadersField :: Field
+httpHeadersField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "field:httpHeaders"#
+
+reportIdField :: Field
+reportIdField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "field:reportId"#
+
+miscellaneousField :: Field
+miscellaneousField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "field:miscellaneous"#
+
+threatIdField :: Field
+threatIdField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "field:threatId"#
+
+deviceNameField :: Field
+deviceNameField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "deviceName"#
+
+actionSourceField :: Field
+actionSourceField = Field (UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "actionSource"#
+
+futureUseAField :: Field
+futureUseAField = Field (UnmanagedBytes (Addr x#) (I# (cstringLen# x#)))
+  where !x# = "futureUse:A"#
+
+futureUseBField :: Field
+futureUseBField = Field (UnmanagedBytes (Addr x#) (I# (cstringLen# x#)))
+  where !x# = "futureUse:B"#
+
+futureUseCField :: Field
+futureUseCField = Field (UnmanagedBytes (Addr x#) (I# (cstringLen# x#)))
+  where !x# = "futureUse:C"#
+
+futureUseDField :: Field
+futureUseDField = Field (UnmanagedBytes (Addr x#) (I# (cstringLen# x#)))
+  where !x# = "futureUse:D"#
+
+futureUseEField :: Field
+futureUseEField = Field (UnmanagedBytes (Addr x#) (I# (cstringLen# x#)))
+  where !x# = "futureUse:E"#
+
+futureUseFField :: Field
+futureUseFField = Field (UnmanagedBytes (Addr x#) (I# (cstringLen# x#)))
+  where !x# = "futureUse:F"#
+
+futureUseGField :: Field
+futureUseGField = Field (UnmanagedBytes (Addr x#) (I# (cstringLen# x#)))
+  where !x# = "futureUse:G"#
+
+leftoversField :: Field
+leftoversField = Field (UnmanagedBytes (Addr x#) (I# (cstringLen# x#)))
+  where !x# = "framing:leftovers"#
+
+sourceVmUuidField :: Field
+sourceVmUuidField = Field (UnmanagedBytes (Addr x#) (I# (cstringLen# x#)))
+  where !x# = "field:source_uuid"#
+
+destinationVmUuidField :: Field
+destinationVmUuidField = Field (UnmanagedBytes (Addr x#) (I# (cstringLen# x#)))
+  where !x# = "field:dst_uuid"#
+
+tunnelIdField :: Field
+tunnelIdField = Field (UnmanagedBytes (Addr x#) (I# (cstringLen# x#)))
+  where !x# = "field:tunnelid"#
+
+monitorTagField :: Field
+monitorTagField = Field (UnmanagedBytes (Addr x#) (I# (cstringLen# x#)))
+  where !x# = "field:monitortag"#
+
+parentSessionIdField :: Field
+parentSessionIdField = Field (UnmanagedBytes (Addr x#) (I# (cstringLen# x#)))
+  where !x# = "field:parent_session_id"#
+
+parentStartTimeField :: Field
+parentStartTimeField = Field (UnmanagedBytes (Addr x#) (I# (cstringLen# x#)))
+  where !x# = "field:parent_start_time"#
+
+tunnelTypeField :: Field
+tunnelTypeField = Field (UnmanagedBytes (Addr x#) (I# (cstringLen# x#)))
+  where !x# = "field:tunnel"#
+
+sctpAssociationIdField :: Field
+sctpAssociationIdField = Field (UnmanagedBytes (Addr x#) (I# (cstringLen# x#)))
+  where !x# = "field:assoc_id"#
+
+sctpChunksField :: Field
+sctpChunksField = Field (UnmanagedBytes (Addr x#) (I# (cstringLen# x#)))
+  where !x# = "field:chunks"#
+
+sctpChunksSentField :: Field
+sctpChunksSentField = Field (UnmanagedBytes (Addr x#) (I# (cstringLen# x#)))
+  where !x# = "field:chunks_sent"#
+
+sctpChunksReceivedField :: Field
+sctpChunksReceivedField = Field (UnmanagedBytes (Addr x#) (I# (cstringLen# x#)))
+  where !x# = "field:chunks_received"#
+
+moduleField :: Field
+moduleField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "field:module"#
+
+descriptionField :: Field
+descriptionField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "field:description"#
+
+eventIdField :: Field
+eventIdField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "field:eventId"#
+
+objectField :: Field
+objectField = Field ( UnmanagedBytes (Addr x#) (I# ( cstringLen# x#)))
+  where !x# = "field:object"#
+
+
+untilSpace :: e -> Parser e s Bounds
+{-# inline untilSpace #-}
+untilSpace e = do
+  start <- Unsafe.cursor
+  Latin.skipTrailedBy e ' '
+  endSucc <- Unsafe.cursor
+  let end = endSucc - 1
+  pure (Bounds start (end - start))
+
+untilComma :: e -> Parser e s Bounds
+{-# inline untilComma #-}
+untilComma e = do
+  start <- Unsafe.cursor
+  Latin.skipTrailedBy e ','
+  endSucc <- Unsafe.cursor
+  let end = endSucc - 1
+  pure (Bounds start (end - start))
+
+skipThroughComma :: e -> Parser e s ()
+{-# inline skipThroughComma #-}
+skipThroughComma e = Latin.skipTrailedBy e ','
+
+-- There should not be any more commas left in the input.
+-- This takes until it finds a comma or until end of input
+-- is reached.
+finalField :: Parser e s Bounds
+{-# inline finalField #-}
+finalField = do
+  start <- Unsafe.cursor
+  Latin.skipUntil ','
+  end <- Unsafe.cursor
+  pure (Bounds start (end - start))
+
+-- This does not require that any digits are
+-- actually present.
+skipDigitsThroughComma :: e -> Parser e s ()
+{-# inline skipDigitsThroughComma #-}
+skipDigitsThroughComma e =
+  Latin.skipDigits *> Latin.char e ','
+
+w64Comma :: e -> Parser e s Word64
+{-# inline w64Comma #-}
+w64Comma e = do
+  w <- Latin.decWord64 e
+  Latin.char e ','
+  pure w
+
+w16Comma :: e -> Parser e s Word16
+{-# inline w16Comma #-}
+w16Comma e = Latin.decWord16 e <* Latin.char e ','
+
+-- Returns the receive time and the serial number. There is a
+-- little subtlety here. The PANOS guide says that logs should
+-- start with something like:
+--   1,2019/07/14 10:26:22,005923187997
+-- The leading field is reserved for future use. However, there
+-- is typically an additional prefix consisting of a syslog priority,
+-- another datetime (in a different format), and a hostname:
+--   <14> Jul 14 11:26:23 MY-HOST.example.com 1,...
+-- The datetime is within typically within a second of the other one.
+-- Additionally, it's missing the year. So, we discard it. The
+-- syslog priority is worthless, so we throw it out as well. The
+-- host name, however, does provide useful information that does
+-- not exist elsewhere in the log. We should be as flexible
+-- as possible with this somewhat fragile part of the log.
+parserPrefix :: Parser Field s (Bounds,Datetime,Bounds)
+{-# inline parserPrefix #-}
+parserPrefix = do
+  Latin.skipChar ' '
+  -- We allow the syslog priority (the number in angle brackets)
+  -- to be absent.
+  Latin.trySatisfy (== '<') >>= \case
+    True -> do
+      Latin.skipTrailedBy syslogPriorityField '>'
+      Latin.skipChar ' '
+    False -> pure ()
+  Ascii.skipAlpha1 syslogDatetimeField -- Month
+  Latin.skipChar1 syslogDatetimeField ' '
+  Latin.skipDigits1 syslogDatetimeField -- Day
+  Latin.skipChar1 syslogDatetimeField ' '
+  Latin.skipDigits1 syslogDatetimeField -- Hour
+  Latin.char syslogDatetimeField ':'
+  Latin.skipDigits1 syslogDatetimeField -- Minute
+  Latin.char syslogDatetimeField ':'
+  Latin.skipDigits1 syslogDatetimeField -- Second
+  Latin.skipChar1 syslogDatetimeField ' '
+  hostBounds <- untilSpace syslogHostField
+  Latin.skipChar ' '
+  skipThroughComma futureUseDField
+  !recv <- parserDatetime receiveTimeDateField receiveTimeTimeField
+  !ser <- untilComma serialNumberField
+  pure (hostBounds,recv,ser)
+
+-- | Decode a PAN-OS syslog message of an unknown type.
+decode :: Bytes -> Either Field Log
+decode b = case P.parseBytes parserLog b of
+  P.Failure e -> Left e
+  P.Success (P.Slice _ len r) -> case len of
+    0 -> Right r
+    _ -> Left leftoversField
+
+parserLog :: Parser Field s Log
+parserLog = do
+  (!hostBounds,!receiveTime,!serialNumber) <- parserPrefix
+  Latin.any typeField >>= \case
+    'S' -> do
+      Latin.char6 typeField 'Y' 'S' 'T' 'E' 'M' ','
+      !x <- parserSystem hostBounds receiveTime serialNumber
+      pure (LogSystem x)
+    'T' -> Latin.any typeField >>= \case
+      'R' -> do
+        Latin.char6 typeField 'A' 'F' 'F' 'I' 'C' ','
+        !x <- parserTraffic hostBounds receiveTime serialNumber
+        pure (LogTraffic x)
+      'H' -> do
+        Latin.char5 typeField 'R' 'E' 'A' 'T' ','
+        !x <- parserThreat hostBounds receiveTime serialNumber
+        pure (LogThreat x)
+      _ -> P.fail typeField
+    _ -> P.fail typeField
+
+parserTraffic :: Bounds -> Datetime -> Bounds -> Parser Field s Traffic
+parserTraffic !syslogHost receiveTime !serialNumber = do
+  subtype <- untilComma subtypeField
+  skipThroughComma futureUseAField
+  -- The datetime parser consumes the trailing comma
+  timeGenerated <- parserDatetime timeGeneratedDateField timeGeneratedTimeField
+  sourceAddress <- IP.fromIPv4 <$> IPv4.parserUtf8Bytes sourceAddressField
+  Latin.char sourceAddressField ','
+  destinationAddress <- IP.fromIPv4 <$> IPv4.parserUtf8Bytes destinationAddressField
+  Latin.char destinationAddressField ','
+  natSourceIp <- IP.fromIPv4 <$> IPv4.parserUtf8Bytes natSourceIpField
+  Latin.char natSourceIpField ','
+  natDestinationIp <- IP.fromIPv4 <$> IPv4.parserUtf8Bytes natDestinationIpField
+  Latin.char natDestinationIpField ','
+  ruleName <- untilComma ruleNameField
+  sourceUser <- untilComma sourceUserField
+  destinationUser <- untilComma destinationUserField
+  application <- untilComma applicationField
+  virtualSystem <- untilComma virtualSystemField
+  sourceZone <- untilComma sourceZoneField
+  destinationZone <- untilComma destinationZoneField
+  inboundInterface <- untilComma inboundInterfaceField
+  outboundInterface <- untilComma outboundInterfaceField
+  logAction <- untilComma logActionField
+  skipThroughComma futureUseBField
+  sessionId <- w64Comma sessionIdField
+  repeatCount <- w64Comma repeatCountField
+  sourcePort <- w16Comma sourcePortField
+  destinationPort <- w16Comma destinationPortField
+  natSourcePort <- w16Comma natSourcePortField
+  natDestinationPort <- w16Comma natDestinationPortField
+  -- TODO: handle the flags
+  Latin.char actionFlagsField '0'
+  Latin.char actionFlagsField 'x'
+  _ <- untilComma flagsField
+  let flags = 0
+  ipProtocol <- untilComma ipProtocolField
+  action <- untilComma actionField
+  bytes <- w64Comma bytesField
+  bytesSent <- w64Comma bytesSentField
+  bytesReceived <- w64Comma bytesReceivedField
+  packets <- w64Comma packetsField
+  startTime <- parserDatetime startTimeDateField startTimeTimeField
+  elapsedTime <- w64Comma elapsedTimeField
+  category <- untilComma categoryField
+  skipThroughComma futureUseCField
+  sequenceNumber <- w64Comma sequenceNumberField
+  -- TODO: handle action flags
+  Latin.char actionFlagsField '0'
+  Latin.char actionFlagsField 'x'
+  _ <- untilComma actionFlagsField
+  let actionFlags = 0
+  sourceCountry <- untilComma sourceCountryField
+  destinationCountry <- untilComma destinationCountryField
+  skipThroughComma futureUseEField
+  packetsSent <- w64Comma packetsSentField
+  packetsReceived <- w64Comma packetsReceivedField
+  sessionEndReason <- untilComma sessionEndReasonField
+  deviceGroupHierarchyLevel1 <- w64Comma deviceGroupHierarchyLevel1Field
+  deviceGroupHierarchyLevel2 <- w64Comma deviceGroupHierarchyLevel2Field
+  deviceGroupHierarchyLevel3 <- w64Comma deviceGroupHierarchyLevel3Field
+  deviceGroupHierarchyLevel4 <- w64Comma deviceGroupHierarchyLevel4Field
+  virtualSystemName <- untilComma virtualSystemNameField
+  deviceName <- untilComma deviceNameField
+  actionSource <- untilComma actionSourceField
+  skipThroughComma sourceVmUuidField
+  skipThroughComma destinationVmUuidField
+  skipThroughComma tunnelIdField
+  skipThroughComma monitorTagField
+  skipThroughComma parentSessionIdField
+  skipThroughComma parentStartTimeField
+  skipThroughComma tunnelTypeField
+  skipThroughComma sctpAssociationIdField
+  skipDigitsThroughComma sctpChunksField
+  skipDigitsThroughComma sctpChunksSentField
+  Latin.skipDigits1 sctpChunksReceivedField
+  message <- Unsafe.expose
+  pure Traffic
+    { subtype , timeGenerated , sourceAddress , destinationAddress 
+    , natSourceIp , natDestinationIp , ruleName , sourceUser 
+    , destinationUser , application , virtualSystem , sourceZone 
+    , destinationZone , inboundInterface , outboundInterface , logAction 
+    , sessionId , repeatCount , sourcePort , destinationPort 
+    , natSourcePort , natDestinationPort , ipProtocol 
+    , action , bytes , bytesSent , bytesReceived 
+    , packets , startTime , elapsedTime , category 
+    , sequenceNumber , sourceCountry , destinationCountry 
+    , packetsSent , sessionEndReason 
+    , deviceGroupHierarchyLevel1 , deviceGroupHierarchyLevel2 
+    , deviceGroupHierarchyLevel3 , deviceGroupHierarchyLevel4 
+    , virtualSystemName , deviceName , actionSource , receiveTime
+    , serialNumber, packetsReceived, actionFlags, flags, message
+    , syslogHost
+    }
+
+parserSystem :: Bounds -> Datetime -> Bounds -> Parser Field s System
+parserSystem syslogHost receiveTime serialNumber = do
+  subtype <- untilComma subtypeField
+  skipThroughComma futureUseAField
+  -- The datetime parser consumes the trailing comma
+  timeGenerated <- parserDatetime timeGeneratedDateField timeGeneratedTimeField
+  virtualSystem <- untilComma virtualSystemField
+  eventId <- untilComma eventIdField
+  object <- untilComma objectField
+  skipThroughComma futureUseBField
+  skipThroughComma futureUseCField
+  module_ <- untilComma moduleField
+  severity <- untilComma severityField
+  Bytes{array=descriptionByteArray,offset=descrOff,length=descrLen} <-
+    parserOptionallyQuoted descriptionField
+  let descriptionBounds = Bounds descrOff descrLen
+  sequenceNumber <- w64Comma sequenceNumberField
+  -- TODO: handle action flags
+  Latin.char actionFlagsField '0'
+  Latin.char actionFlagsField 'x'
+  _ <- untilComma actionFlagsField
+  let actionFlags = 0
+  deviceGroupHierarchyLevel1 <- w64Comma deviceGroupHierarchyLevel1Field
+  deviceGroupHierarchyLevel2 <- w64Comma deviceGroupHierarchyLevel2Field
+  deviceGroupHierarchyLevel3 <- w64Comma deviceGroupHierarchyLevel3Field
+  deviceGroupHierarchyLevel4 <- w64Comma deviceGroupHierarchyLevel4Field
+  virtualSystemName <- untilComma virtualSystemNameField
+  deviceName <- finalField
+  message <- Unsafe.expose
+  pure System
+    { subtype , timeGenerated
+    , sequenceNumber 
+    , deviceGroupHierarchyLevel1 , deviceGroupHierarchyLevel2 
+    , deviceGroupHierarchyLevel3 , deviceGroupHierarchyLevel4 
+    , virtualSystemName , deviceName , receiveTime
+    , serialNumber, actionFlags, message
+    , syslogHost, virtualSystem, eventId, object, module_
+    , severity, descriptionBounds, descriptionByteArray
+    }
+
+parserThreat :: Bounds -> Datetime -> Bounds -> Parser Field s Threat
+parserThreat !syslogHost receiveTime !serialNumber = do
+  subtype <- untilComma subtypeField
+  skipThroughComma futureUseAField
+  -- the datetime parser also grabs the trailing comma
+  timeGenerated <- parserDatetime timeGeneratedDateField timeGeneratedTimeField
+  sourceAddress <- IP.fromIPv4 <$> IPv4.parserUtf8Bytes sourceAddressField
+  Latin.char sourceAddressField ','
+  destinationAddress <- IP.fromIPv4 <$> IPv4.parserUtf8Bytes destinationAddressField
+  Latin.char destinationAddressField ','
+  natSourceIp <- IP.fromIPv4 <$> IPv4.parserUtf8Bytes natSourceIpField
+  Latin.char natSourceIpField ','
+  natDestinationIp <- IP.fromIPv4 <$> IPv4.parserUtf8Bytes natDestinationIpField
+  Latin.char natDestinationIpField ','
+  ruleName <- untilComma ruleNameField
+  sourceUser <- untilComma sourceUserField
+  destinationUser <- untilComma destinationUserField
+  application <- untilComma applicationField
+  virtualSystem <- untilComma virtualSystemField
+  sourceZone <- untilComma sourceZoneField
+  destinationZone <- untilComma destinationZoneField
+  inboundInterface <- untilComma inboundInterfaceField
+  outboundInterface <- untilComma outboundInterfaceField
+  logAction <- untilComma logActionField
+  skipThroughComma futureUseBField
+  sessionId <- w64Comma sessionIdField
+  repeatCount <- w64Comma repeatCountField
+  sourcePort <- w16Comma sourcePortField
+  destinationPort <- w16Comma destinationPortField
+  natSourcePort <- w16Comma natSourcePortField
+  natDestinationPort <- w16Comma natDestinationPortField
+  -- TODO: handle the flags
+  Latin.char actionFlagsField '0'
+  Latin.char actionFlagsField 'x'
+  _ <- untilComma flagsField
+  let flags = 0
+  ipProtocol <- untilComma ipProtocolField
+  action <- untilComma actionField
+  Bytes{array=miscellaneousByteArray,offset=miscOff,length=miscLen} <-
+    parserOptionallyQuoted miscellaneousField
+  let miscellaneousBounds = Bounds miscOff miscLen
+  (threatName,threatId) <- parserThreatId
+  category <- untilComma categoryField
+  severity <- untilComma severityField
+  direction <- untilComma directionField
+  sequenceNumber <- w64Comma sequenceNumberField
+  -- TODO: handle action flags
+  Latin.char actionFlagsField '0'
+  Latin.char actionFlagsField 'x'
+  _ <- untilComma actionFlagsField
+  let actionFlags = 0
+  sourceCountry <- untilComma sourceCountryField
+  destinationCountry <- untilComma destinationCountryField
+  skipThroughComma futureUseEField
+  contentType <- untilComma contentTypeField
+  pcapId <- w64Comma pcapIdField
+  fileDigest <- untilComma fileDigestField
+  cloud <- untilComma cloudField
+  urlIndex <- w64Comma urlIndexField
+  Bytes{array=userAgentByteArray,offset=uaOff,length=uaLen} <-
+    parserOptionallyQuoted userAgentField
+  let userAgentBounds = Bounds uaOff uaLen
+  fileType <- untilComma fileTypeField
+  forwardedFor <- untilComma forwardedForField
+  referer <- parserOptionallyQuoted refererField
+  sender <- parserOptionallyQuoted senderField
+  subject <- parserOptionallyQuoted subjectField
+  recipient <- parserOptionallyQuoted recipientField
+  reportId <- untilComma reportIdField
+  deviceGroupHierarchyLevel1 <- w64Comma deviceGroupHierarchyLevel1Field
+  deviceGroupHierarchyLevel2 <- w64Comma deviceGroupHierarchyLevel2Field
+  deviceGroupHierarchyLevel3 <- w64Comma deviceGroupHierarchyLevel3Field
+  deviceGroupHierarchyLevel4 <- w64Comma deviceGroupHierarchyLevel4Field
+  virtualSystemName <- untilComma virtualSystemNameField
+  deviceName <- untilComma deviceNameField
+  parserOptionallyQuoted_ futureUseFField
+  skipThroughComma sourceVmUuidField
+  skipThroughComma destinationVmUuidField
+  httpMethod <- untilComma httpMethodField
+  tunnelId <- w64Comma tunnelIdField
+  skipThroughComma monitorTagField
+  parentSessionId <- w64Comma parentSessionIdField
+  skipThroughComma parentStartTimeField
+  skipThroughComma tunnelTypeField
+  threatCategory <- untilComma threatCategoryField
+  contentVersion <- untilComma contentVersionField
+  skipThroughComma futureUseGField
+  sctpAssociationId <- w64Comma sctpAssociationIdField
+  payloadProtocolId <- w64Comma payloadProtocolField
+  -- TODO: Handle HTTP Headers correctly
+  httpHeaders <- finalOptionallyQuoted httpHeadersField
+  message <- Unsafe.expose
+  pure Threat
+    { subtype , timeGenerated , sourceAddress , destinationAddress 
+    , natSourceIp , natDestinationIp , ruleName , sourceUser 
+    , destinationUser , application , virtualSystem , sourceZone 
+    , destinationZone , inboundInterface , outboundInterface , logAction 
+    , sessionId , repeatCount , sourcePort , destinationPort 
+    , natSourcePort , natDestinationPort , ipProtocol 
+    , action , category 
+    , sequenceNumber , sourceCountry , destinationCountry 
+    , deviceGroupHierarchyLevel1 , deviceGroupHierarchyLevel2 
+    , deviceGroupHierarchyLevel3 , deviceGroupHierarchyLevel4 
+    , virtualSystemName , deviceName , receiveTime
+    , serialNumber, actionFlags, flags, message
+    , syslogHost, threatId, severity, direction, threatName
+    , contentType, pcapId
+    , fileDigest, cloud, urlIndex
+    , userAgentBounds, sctpAssociationId
+    , userAgentByteArray, fileType
+    , forwardedFor, referer
+    , sender, subject, recipient
+    , reportId, httpMethod, contentVersion
+    , threatCategory, miscellaneousBounds, miscellaneousByteArray
+    , payloadProtocolId, parentSessionId, tunnelId
+    , httpHeaders
+    }
+
+-- Threat IDs are weird. There are three different kinds of
+-- strings that can show up here:
+--
+-- * (9999)
+-- * Microsoft RPC Endpoint Mapper Detection(30845)
+-- * Windows Executable (EXE)(52020)
+--
+-- URL logs have a threat id of 9999, and there is no description.
+-- Everything else has a human-readable description. Sometimes,
+-- this description is suffixed by a space and a parenthesized
+-- acronym (EXE, DLL, etc.).
+parserThreatId :: Parser Field s (Bounds,Word64)
+parserThreatId = Latin.any threatIdField >>= \case
+  '(' -> do
+    theId <- Latin.decWord64 threatIdField
+    Latin.char threatIdField ')'
+    Latin.char threatIdField ','
+    pure (Bounds 0 0, theId)
+  _ -> do
+    startSucc <- Unsafe.cursor
+    Latin.skipTrailedBy threatIdField '('
+    end <- Latin.trySatisfy (\c -> isAsciiUpper c || isAsciiLower c) >>= \case
+      True -> do
+        endSuccSucc <- Unsafe.cursor
+        Latin.skipTrailedBy threatIdField '('
+        arr <- Unsafe.expose
+        -- We go back an extra character to remove the trailing
+        -- space. I do not believe this can lead to negative-length
+        -- slices, but the line of reasoning is muddy.
+        case indexCharArray arr (endSuccSucc - 3) of
+          ' ' -> pure (endSuccSucc - 3)
+          _ -> P.fail threatIdField
+      False -> do
+        endSucc <- Unsafe.cursor
+        pure (endSucc - 1)
+    theId <- Latin.decWord64 threatIdField
+    Latin.char threatIdField ')'
+    Latin.char threatIdField ','
+    let start = startSucc - 1
+    pure (Bounds start (end - start), theId)
+
+parserOptionallyQuoted_ :: e -> Parser e s ()
+parserOptionallyQuoted_ e = Latin.any e >>= \case
+  '"' -> do
+    _ <- consumeQuoted e 0
+    pure ()
+  ',' -> pure ()
+  _ -> Latin.skipTrailedBy e ','
+
+-- Precondition: the cursor is placed at the beginning of the
+-- possibly-quoted content. That is, the comma preceeding has
+-- already been consumed. This is very similar to parserOptionallyQuoted,
+-- but it differs slightly because there is no trailing comma. 
+finalOptionallyQuoted :: e -> Parser e s Bytes
+finalOptionallyQuoted e = Latin.opt >>= \case
+  Nothing -> do
+    !array <- Unsafe.expose
+    pure $! Bytes{array,offset=0,length=0}
+  Just c -> case c of
+    '"' -> do
+      -- First, we do a run through just to see if anything
+      -- actually needs to be escaped.
+      start <- Unsafe.cursor
+      !n <- consumeFinalQuoted e 0
+      !array <- Unsafe.expose
+      !endSucc <- Unsafe.cursor
+      let end = endSucc - 1
+      if n == 0
+        then pure Bytes{array,offset=start,length=(end - start)}
+        else do
+          let !r = escapeQuotes Bytes{array,offset=start,length=(end - start)}
+          pure $! Bytes{array=r,offset=0,length=PM.sizeofByteArray r}
+    _ -> do
+      !startSucc <- Unsafe.cursor
+      Latin.skipUntil ','
+      !end <- Unsafe.cursor
+      !arr <- Unsafe.expose
+      let start = startSucc - 1
+      pure $! Bytes arr start (end - start)
+
+-- Precondition: the cursor is placed at the beginning of the
+-- possibly-quoted content. That is, the comma preceeding has
+-- already been consumed.
+parserOptionallyQuoted :: e -> Parser e s Bytes
+parserOptionallyQuoted e = Latin.any e >>= \case
+  '"' -> do
+    -- First, we do a run through just to see if anything
+    -- actually needs to be escaped.
+    start <- Unsafe.cursor
+    !n <- consumeQuoted e 0
+    !array <- Unsafe.expose
+    !endSuccSucc <- Unsafe.cursor
+    let end = endSuccSucc - 2
+    if n == 0
+      then pure Bytes{array,offset=start,length=(end - start)}
+      else do
+        let !r = escapeQuotes Bytes{array,offset=start,length=(end - start)}
+        pure $! Bytes{array=r,offset=0,length=PM.sizeofByteArray r}
+  ',' -> do
+    !array <- Unsafe.expose
+    pure $! Bytes{array,offset=0,length=0}
+  _ -> do
+    !startSucc <- Unsafe.cursor
+    Latin.skipTrailedBy e ','
+    !endSucc <- Unsafe.cursor
+    !arr <- Unsafe.expose
+    let start = startSucc - 1
+    let end = endSucc - 1
+    pure $! (Bytes arr start (end - start))
+
+-- Precondition: the input is a valid CSV-style quoted-escaped
+-- string. That is, any double quote character is guaranteed to
+-- be followed by another one.
+escapeQuotes :: Bytes -> ByteArray
+escapeQuotes (Bytes arr off0 len0) = runByteArrayST $ do
+  marr <- PM.newByteArray len0
+  let go !soff !doff !len = if len > 0
+        then do
+          let w :: Word8 = PM.indexByteArray arr soff
+          PM.writeByteArray marr doff w
+          if w /= c2w '"'
+            then go (soff + 1) (doff + 1) (len - 1)
+            else go (soff + 2) (doff + 1) (len - 2)
+        else pure doff
+  finalSz <- go off0 0 len0
+  marr' <- PM.resizeMutableByteArray marr finalSz
+  PM.unsafeFreezeByteArray marr'
+
+-- When this parser completed, the position in the input will be
+-- just after the comma that followed the quoted field.
+-- This is defined recursively.
+consumeQuoted ::
+     e
+  -> Int -- the number of escaped quotes we have encountered
+  -> Parser e s Int
+consumeQuoted e !n = do
+  Latin.skipTrailedBy e '"'
+  Latin.any e >>= \case
+    ',' -> pure n
+    '"' -> consumeQuoted e (n + 1)
+    _ -> P.fail e
+
+-- Like consumeQuoted except that we are expected end-of-input
+-- instead of a comma at the end.
+consumeFinalQuoted ::
+     e
+  -> Int -- the number of escaped quotes we have encountered
+  -> Parser e s Int
+consumeFinalQuoted e !n = do
+  Latin.skipTrailedBy e '"'
+  Latin.opt >>= \case
+    Nothing -> pure n
+    Just c -> case c of
+      '"' -> consumeFinalQuoted e (n + 1)
+      _ -> P.fail e
+
+parserDatetime :: e -> e -> Parser e s Datetime
+{-# noinline parserDatetime #-}
+parserDatetime edate etime = do
+  year <- Latin.decWord edate
+  Latin.char edate '/'
+  monthPlusOne <- Latin.decWord edate
+  let month = monthPlusOne - 1
+  if month > 11
+    then P.fail edate
+    else pure ()
+  Latin.char edate '/'
+  day <- Latin.decWord edate
+  Latin.char etime ' '
+  hour <- Latin.decWord etime
+  Latin.char etime ':'
+  minute <- Latin.decWord etime
+  Latin.char etime ':'
+  second <- Latin.decWord etime
+  Latin.char etime ','
+  pure $ Datetime
+    (Date
+      (Year (fromIntegral year))
+      (Month (fromIntegral month))
+      (DayOfMonth (fromIntegral day))
+    )
+    (TimeOfDay
+      (fromIntegral hour)
+      (fromIntegral minute)
+      (1_000_000_000 * fromIntegral second)
+    )
+
+cstringLen# :: Addr# -> Int#
+{-# noinline cstringLen# #-}
+cstringLen# ptr = go 0 where
+  go !ix@(I# ix#) = if PM.indexOffPtr (Ptr ptr) ix == (0 :: Word8)
+    then ix#
+    else go (ix + 1)
+
+c2w :: Char -> Word8
+c2w = fromIntegral . ord
+
+indexCharArray :: ByteArray -> Int -> Char
+indexCharArray (PM.ByteArray x) (I# i) =
+  Exts.C# (Exts.indexCharArray# x i)
diff --git a/test/Main.hs b/test/Main.hs
new file mode 100644
--- /dev/null
+++ b/test/Main.hs
@@ -0,0 +1,219 @@
+{-# language BangPatterns #-}
+{-# language MultiWayIf #-}
+{-# language ScopedTypeVariables #-}
+{-# language TypeApplications #-}
+
+import Panos.Syslog (Log(..),decode)
+
+import Control.Exception (throwIO)
+import Data.Primitive (ByteArray)
+import Data.Word (Word8)
+import Data.Char (ord,chr)
+import Data.Bytes.Types (Bytes(Bytes))
+
+import qualified Panos.Syslog.Traffic as Traffic
+import qualified Panos.Syslog.Threat as Threat
+import qualified Panos.Syslog.System as System
+import qualified Data.Primitive as PM
+import qualified GHC.Exts as Exts
+import qualified Sample as S
+
+main :: IO ()
+main = do
+  putStrLn "Start"
+  putStrLn "8.1-Traffic-A"
+  testA
+  putStrLn "8.1-Traffic-B"
+  testTrafficB
+  putStrLn "8.1-Threat-A"
+  testB
+  putStrLn "8.1-Threat-B"
+  testC
+  putStrLn "8.1-Threat-C"
+  testD
+  putStrLn "8.1-Threat-D"
+  testThreatD
+  putStrLn "8.1-Threat-E"
+  testThreatE
+  putStrLn "8.1-Threat-F"
+  testThreatF
+  putStrLn "8.1-Threat-G"
+  testThreatG
+  putStrLn "8.1-Threat-H"
+  testThreatH
+  putStrLn "8.1-Threat-I"
+  testThreatI
+  putStrLn "8.1-System-A"
+  testSystemA
+  putStrLn "Finished"
+
+testA :: IO ()
+testA = case decode S.traffic_8_1_A of
+  Left err -> throwIO err
+  Right (LogTraffic t) ->
+    if | Traffic.deviceName t /= bytes "MY-DEVICE-NAME" ->
+           fail $
+             "wrong device name:\nexpected: " ++
+             show (bytes "MY-DEVICE-NAME") ++
+             "\nactually: " ++
+             show (Traffic.deviceName t)
+       | Traffic.packetsReceived t /= 0 ->
+           fail $
+             "wrong packets received: expected 0 but got " ++
+             show (Traffic.packetsReceived t)
+       | Traffic.bytesReceived t /= 34 ->
+           fail $
+             "wrong bytes received: expected 34 but got " ++
+             show (Traffic.bytesReceived t)
+       | otherwise -> pure ()
+  Right _ -> fail "wrong log type"
+
+testB :: IO ()
+testB = case decode S.threat_8_1_A of
+  Left err -> throwIO err
+  Right (LogThreat t) ->
+    if | Threat.contentVersion t /= bytes "AppThreat-1-6" -> fail $
+           "wrong content version:\nExpected: AppThreat-1-6\nActually: " ++
+           prettyBytes (Threat.contentVersion t)
+       | Threat.miscellaneous t /= bytes "www.example.com/string/\"hello\"" -> fail $
+           "wrong miscellaneous (URL):\nExpected: www.example.com\nActually: " ++
+           prettyBytes (Threat.miscellaneous t)
+       | otherwise -> pure ()
+  Right _ -> fail "wrong log type"
+
+testTrafficB :: IO ()
+testTrafficB = case decode S.traffic_8_1_B of
+  Left err -> throwIO err
+  Right (LogTraffic t) ->
+    if | Traffic.sourceUser t /= bytes "example\\jdoe" ->
+           fail $
+             "wrong source user:\nexpected: " ++
+             show (bytes "MY-DEVICE-NAME") ++
+             "\nactually: " ++
+             show (Traffic.deviceName t)
+       | otherwise -> pure ()
+  Right _ -> fail "wrong log type"
+
+testC :: IO ()
+testC = case decode S.threat_8_1_B of
+  Left err -> throwIO err
+  Right (LogThreat t) ->
+    if | Threat.miscellaneous t /= bytes "www.example.com/" -> fail $
+           "wrong miscellaneous (URL):\nExpected: www.example.com/\nActually: " ++
+           prettyBytes (Threat.miscellaneous t) ++ "\n"
+       | Threat.virtualSystemName t /= bytes "the-vsys-name" -> fail $
+           "wrong vsys name:\nExpected: the-vsys-name\nActually: " ++
+           prettyBytes (Threat.virtualSystemName t) ++ "\n"
+       | Threat.contentVersion t /= bytes "AppThreat-3-7" -> fail $
+           "wrong content version:\nExpected: AppThreat-3-7\nActually: " ++
+           prettyBytes (Threat.contentVersion t) ++ "\n"
+       | otherwise -> pure ()
+  Right _ -> fail "wrong log type"
+
+testD :: IO ()
+testD = case decode S.threat_8_1_C of
+  Left err -> throwIO err
+  Right (LogThreat t) ->
+    if | Threat.threatId t /= 30845 -> fail $
+           "wrong threat id:\nExpected: 30845\nActually: " ++
+           show (Threat.threatId t) ++ "\n"
+       | Threat.threatName t /= bytes "Microsoft RPC Endpoint Mapper Detection" -> fail $
+           "wrong threat name:\nExpected: Microsoft RPC Endpoint Mapper Detection\nActually: " ++
+           prettyBytes (Threat.threatName t) ++ "\n"
+       | otherwise -> pure ()
+  Right _ -> fail "wrong log type"
+
+testThreatD :: IO ()
+testThreatD = case decode S.threat_8_1_D of
+  Left err -> throwIO err
+  Right (LogThreat t) ->
+    if | Threat.threatName t /= bytes "Email Link" -> fail $
+           "wrong threat name:\nExpected: Email Link\nActually: " ++
+           prettyBytes (Threat.threatName t) ++ "\n"
+       | Threat.sender t /= bytes "From: \"John Doe\" <jdoe@example.com>" ->
+           fail $ "wrong sender"
+       | otherwise -> pure ()
+  Right _ -> fail "wrong log type"
+
+testThreatE :: IO ()
+testThreatE = case decode S.threat_8_1_E of
+  Left err -> throwIO err
+  Right (LogThreat t) ->
+    if | Threat.threatName t /= bytes "Windows Executable" -> fail $
+           "wrong threat name:\nExpected: Windows Executable\nActually: " ++
+           prettyBytes (Threat.threatName t) ++ "\n"
+       | otherwise -> pure ()
+  Right _ -> fail "wrong log type"
+
+testThreatF :: IO ()
+testThreatF = case decode S.threat_8_1_F of
+  Left err -> throwIO err
+  Right (LogThreat t) ->
+    if | Threat.threatName t /= bytes "Temporary TMP File" ->
+           fail "wrong threat name"
+       | otherwise -> pure ()
+  Right _ -> fail "wrong log type"
+
+testThreatG :: IO ()
+testThreatG = case decode S.threat_8_1_G of
+  Left err -> throwIO err
+  Right (LogThreat t) ->
+    if | Threat.threatName t /= bytes "Hypertext Preprocessor PHP File" ->
+           fail "wrong threat name"
+       | Threat.httpHeaders t /= bytes "contextual.media.net/" ->
+           fail "wrong http headers"
+       | otherwise -> pure ()
+  Right _ -> fail "wrong log type"
+
+testThreatH :: IO ()
+testThreatH = case decode S.threat_8_1_H of
+  Left err -> throwIO err
+  Right (LogThreat t) ->
+    if | Threat.threatId t /= 9999 ->
+           fail "wrong threat id"
+       | otherwise -> pure ()
+  Right _ -> fail "wrong log type"
+
+testThreatI :: IO ()
+testThreatI = case decode S.threat_8_1_I of
+  Left err -> throwIO err
+  Right (LogThreat t) ->
+    if | Threat.threatId t /= 320511141 ->
+           fail "wrong threat id"
+       | otherwise -> pure ()
+  Right _ -> fail "wrong log type"
+
+testSystemA :: IO ()
+testSystemA = case decode S.system_8_1_A of
+  Left err -> throwIO err
+  Right (LogSystem t) ->
+    if | System.deviceName t /= bytes "NY-DC-FW-2" -> fail $
+           "wrong device name:\nexpected: NY-DC-FW-2\nactually: " ++
+           show (System.deviceName t)
+       | System.description t /= bytes
+           ( concat
+             [ "IKE protocol IPSec SA delete message "
+             , "sent to peer. SPI:0xA1CD910F."
+             ]
+           ) -> fail $
+             "wrong description:\nexpected something about IKE\nactually: " ++
+             show (System.description t)
+       | otherwise -> pure ()
+  Right _ -> fail "wrong log type"
+
+
+bytes :: String -> Bytes
+bytes s = let b = pack s in Bytes b 0 (PM.sizeofByteArray b)
+
+pack :: String -> ByteArray
+pack = Exts.fromList . map (fromIntegral @Int @Word8 . ord)
+
+prettyBytes :: Bytes -> String
+prettyBytes (Bytes arr off len) = if len > 0
+  then
+    let w = PM.indexByteArray arr off :: Word8
+        c = if w > 31 && w < 127
+              then chr (fromIntegral w)
+              else 'X'
+     in c : prettyBytes (Bytes arr (off + 1) (len - 1))
+  else []
