See Plugin Architecture prior to reading this section.
At the highest level, all plugins are Python modules.
There are two types of plugin module:
Plugin modules should be placed in their respective parent directories within the root plugins
directory. For example:
sendlog # repository root
└── sendlog
└── plugins # root plugins directory
├── channels # channel plugin modules go in here
│ ├── file.py
│ ├── smtp.py
│ ├── stdout.py
│ ├── telegram.py
│ └── twilio_sms.py
└── logs # log plugin modules go in here
├── auth.py
├── sendlog.py
└── systemd.py
While modules provide a layer of convenience when managing plugins, the plugin system does not recognise or enforce them beyond the import constraints shown above.
Plugin modules contain one or more plugin class. These classes represent nodes on a worktree: processes that convert a log to a payload or send it to an endpoint.
Generally speaking, plugin classes are created by extending the relevant superclass and implementing the __call__
method. If plugins do not inherit from the expected base, they will be rejected.
Plugin superclasses can be imported from the plugin
module. However, plugin superclasses will vary depending on which module you are writing for.
Channel modules should only define one class: that extending the Channel superclass, which defines how an alert gets to an endpoint.
All custom channel classes must extend the Channel superclass:
class Channel(ABC, metaclass=_ChannelMeta):
__slots__ = ["name"]
def __init__(self, name, **kwargs):
self.name = name
for key, value in kwargs.items():
setattr(self, key, value)
@abstractmethod
def __call__(self, msg):
pass
Explanation:
__call__
method in subclasses.__slots__
within the subclass.Constraints:
__init__
method for Channel subclasses.__call__
method should contain exactly one additional parameter (msg
is suggested) to accomodate the alert payload.Below is an example of a Channel plugin module, which contains a Channel plugin class:
from plugin import Channel # import Channel superclass
import requests
class Telegram(Channel): # must inherit from Channel superclass
__slots__ = ["chat_id", "token"] # Channel variables
# entrypoint method
def __call__(self, msg): # must take an additional parameter
url = f"https://api.telegram.org/bot{self.token}/sendMessage"
params = {"chat_id": self.chat_id, "text": msg}
requests.post(url, params=params,)
Explanation
Channel
is imported from the plugin
module.Telegram
extends Channel
.chat_id
and token
are specified in __slots__
.__call__
method is the entrypoint for delivery.telegram("Hello!")
), it sends a message using Telegram’s Bot API.Log modules define how logs are matched, filtered and transformed by extending the LogType
, Rule
, and Transformer
superclasses.
The DSL allows you to create modular, hierarchical definitions that map directly to components of log processing:
The hierarchical constraints of these components are maintained by nesting:
from plugin import LogType, Rule, Transformer
class MyLogType(LogType): # Top-level log source plugin
class MyRule(Rule): # A specific type of event
class MyTransformer(Transformer): # Transforms matched event into payload
pass
Currently, sendlog only permits workflows that consist of a LogType
, Rule
, and Transformer
. If we think of the structure as a tree:
MyLogType
) must extend LogType
MyRule
) must extend Rule
MyTransformer
) must extend Transformer
Like all plugins, the __call__
method defines how the class performs its’ function. The output (return
value) of one plugin is chained to the subnodes in the tree. However, some of these plugins have default implementations that can simplify the plugin definition.
The LogType
superclass defines how logs are matched to a generic structure. It is best used to extract common fields from semi-structured log sources.
class LogType(ABC, metaclass=_LogTypeMeta):
"""
Base class for a LogType plugin.
"""
regex = None
def __call__(self, log_line: str):
"""
Convert log_line to structured JSON using regex.
Must return a dictionary with the "message" key.
"""
if self.__class__.regex:
match = re.match(self.__class__.regex, log_line)
if match:
log_parts = match.groupdict()
return log_parts
else:
raise TypeError("Log line did not match the expected format.")
else:
return {"message": log_line}
Explanation:
LogType
contains a default __call__
method that uses the class-level regex
variable to create a dictionary from the provided log line.regex
variable must contain a valid named-group regular expression.__call__
method can be overriden in a subclass to use a different matching technique.Constraints:
__init__
method can only contain the self
parameter.__call__
method should contain exactly one additional parameter (log_line
is suggested) to accomodate the log line.__call__
method must return a value that is compatible with all subnodes.Rule
plugins are similar to LogType
plugins, but they do not raise errors when the message doesn’t match. They extend the Rule
superclass:
class Rule(ABC, metaclass=_RuleMeta):
regex = None
def __call__(self, log_parts: dict):
"""Convert log message to structured JSON."""
if self.__class__.regex:
match = re.match(self.__class__.regex, log_parts["message"])
if match:
message_parts = match.groupdict()
return {**log_parts, "context": {**message_parts}}
return False
Explanation
Rule
contains a default __call__
method that uses the class-level regex
variable to create a dictionary from the provided log line.None
or False
, the rule will trigger an alert.regex
variable must contain a valid named-group regular expression.__call__
method can be overriden in a subclass to use a different matching technique.False
, and will never trigger an alert.Rule
plugin expects a message
key from the parent LogType
; this is used for the matching process.Constraints:
__init__
method can only contain the self
parameter.__call__
method should contain exactly one additional parameter (log_line
is suggested) to accomodate the log line.__call__
method must return a value that is compatible with all subnodes.class Transformer(ABC, metaclass=_TransformerMeta):
def __call__(self, log_parts: dict):
return log_parts["message"]
Explanation
Transformer
contains a default __call__
method that simply returns the message
key from the provided dictionary.__call__
method can be overriden in a subclass to define a custom transformation.Constraints
__init__
method can only contain the self
parameter.__call__
method should contain exactly one additional parameter (log_line
is suggested) to accomodate the log line.from plugin import LogType, Rule, Transformer
from datetime import datetime
class Pacman(LogType):
regex = r"\[(?P<timestamp>.*?)\] \[(?P<application>.*?)\] (?P<message>.*)"
class RunCommand(Rule):
regex = r"Running\s+'(?P<command>[^']+)'"
class HumanReadable(Transformer):
def __call__(self, parts):
context = parts["context"]
timestamp = datetime.strptime(parts["timestamp"], "%Y-%m-%dT%H:%M:%S%z")
return f"Command '{context["command"]}' detected at {timestamp.strftime("%Y-%m-%d %H:%M")}."
class JSONL(Transformer):
def __call__(self, parts):
return parts
Explanation:
Pacman
LogType produces a dictionary with the timestamp
, application
, and message
keys.RunCommand
Rule tests whether the message
matches a regular expression indicating a specific log event.HumanReadable
Transformer converts the log into a convenient human-readable message using the extracted values.JSONL
Transformer simply returns the log in dictionary form.