packages feed

logplex-parse-0.1.0.2: src/Text/Syslog/Parser.hs

{-# LANGUAGE FlexibleContexts #-}

module Text.Syslog.Parser (
  parseSyslog,

  LogEntry(..),

  StructuredData(..),
  Key,
  Value,
  Param,

  nonZeroDigit,
) where

import Control.Monad
import Data.Maybe

import Text.ParserCombinators.Parsec

import Data.Time.ISO8601
import Data.Time.Clock
import Text.Printf

data LogEntry = LogEntry { getPriority :: String
                         , getVersion :: String
                         , getTimestamp :: UTCTime
                         , getHostname :: String
                         , getAppname :: String
                         , getProcessId :: String
                         , getMessageId :: String
                         , getStructuredData :: [StructuredData]
                         , getMessage :: Maybe String
                         } deriving (Show, Eq)

data StructuredData = StructuredData { getId :: String
                                     , getParams :: [Param]
                                     } deriving (Show, Eq)

type Key = String
type Value = String
type Param = (Key, Value)

parseSyslog :: String -> Either ParseError LogEntry
parseSyslog = parse syslogLine "(unknown)"

syslogLine = LogEntry <$>
             pri <*>
             version <*>
             (space >> timestamp) <*>
             (space >> hostname) <*>
             (space >> appName) <*>
             (space >> procid) <*>
             (space >> msgid) <*>
             -- this doesn't strictly conform to the RFC5424 BNF, but Heroku generates syslog
             -- messages that omit the nilvalue in place of structured data
            (fromMaybe [] <$> optionMaybe (try (space >> structuredData))) <*>
             optionMaybe (space >> message) <*
             eof

pri = between (char '<') (char '>') (countBetween 1 3 digit) <?> "priority value"
nonZeroDigit = oneOf "123456789"
version = liftM2 (:) nonZeroDigit (countBetween 0 2 digit) <?> "syslog protocol version"

timestamp = do
  time <- (nilvalue >> return "") <|> (mconcat <$> sequence [fullDate, string "T", fullTime]) <?> "ISO8601 full date time timestamp with timezone"
  case parseISO8601 time of
    Nothing -> fail $ printf "invalid timestamp `%s`" time
    Just t  -> return t

nilvalue = string "-"

fullDate = mconcat <$> sequence [count 4 digit, string "-", count 2 digit, string "-", count 2 digit]

fullTime = liftM2 (++) partialTime timeOffset

partialTime = mconcat <$> sequence [time, string ":", second, fromMaybe "" <$> optionMaybe (liftM2 (++) (string ".") (countBetween 1 6 digit))]

timeHour = count 2 digit
timeMinute = count 2 digit
second = count 2 digit
timeOffset = string "Z" <|> timeNumOffset
timeNumOffset = liftM2 (:) (oneOf "+-") time
time = mconcat <$> sequence [timeHour, string ":", timeMinute]

hostname = nilvalue <|> countBetween 1 255 printascii <?> "hostname"

printascii = oneOf printasciiSet
printasciiSet = toEnum <$> [33..126] :: String

appName = nilvalue <|> countBetween 1 48 printascii <?> "application name"
procid = nilvalue <|> countBetween 1 128 printascii <?> "process id"
msgid = nilvalue <|> countBetween 1 32 printascii <?> "message id"

structuredData = (nilvalue >> return []) <|> many1 sdElement

sdElement = between (char '[') (char ']') (liftM2 StructuredData sdId (many (space >> sdParam)))

sdId = sdName

sdName = countBetween 1 32 (oneOf (filter (`notElem` "= ]\"") printasciiSet))

sdParam = liftM2 (,) paramName (string "=" >> quoted (many1 $ escaped '\\' "\"]"))

quoted = between doubleQuote doubleQuote
doubleQuote = char '"'

paramName = sdName

escaped echar chars = let echars = echar:chars
                       in noneOf echars <|> choice ((try . (char echar >>) . char) <$> echars)

message = msgUtf8 <|> msgAny <?> "message"

msgUtf8 = try (bom >> utf8String)
utf8String = many anyChar
bom = string ['\xEF', '\xBB', '\xBF']

msgAny = utf8String

{-# CONTRACT countBetween :: { x y | x < y } -> Ok #-}
countBetween min' max' parser
  | min' > max'  = error "min occurences cannot be greater than max occurrences"
  | min' == max' = count max' parser
  | otherwise    = try (count max' parser) <|> countBetween min' (max'-1) parser