Writing a simple Seq App in Rust
Seq Apps are plug-ins for Seq that process incoming events. People have written Seq apps for all kinds of purposes: sending error notifications to Slack or email, raising issues in trackers like Jira and YouTrack, firing off pagers, integrating with other monitoring systems, and the list goes on.
Since they were introduced, plug-ins have been written in C# and hosted using .NET’s AppDomain
infrastructure to ensure apps get the right versions of assemblies they depend on, and can be loaded and unloaded cleanly. This has worked really well: apps mostly “just work” and we don’t think about the plumbing very much.
There’s always room for improvement, though. The F5/debugging experience for app development isn’t great, since you need Seq’s internal hosting infrastructure to get an app running. More critically, the AppDomain
-based hosting infrastructure uses crufty .NET APIs that are long-in-the-tooth and aren’t portable to .NET Core and Linux.
In Seq 4.2, as part of the longer-term move to cross-platform deployment, we’ve scrapped AppDomain
isolation in favor of process isolation. Existing apps, based on .NET assemblies, will work as-is, but they’ll run in a new generic .NET host process.
Excitingly, new apps can be written as executables using any version of .NET, or another language entirely! There are already lots of C# apps out there. To test the other half of this story (and have some fun), I decided to write a simple Seq App in my other-favorite language, Rust.
The app itself is going to look for a numeric property on incoming log events, sum up the values, and print the running total. This will exercise all the moving parts that more realistic apps will need - configuration, events in, and events out. To see this in action, read on! You’ll need to grab the latest preview build from https://datalust.co/download if you’re reading this prior to the final 4.2 release.
File/New Project
With the Rust toolchain installed (I’m working on Windows 10), cargo new
lays out everything on disk to get a standalone .exe
built and running:
> cargo new total --bin
Created binary (application) `total` project
> cd total
> cargo run
Compiling total v0.1.0 (file:///C:/Development/total)
Finished dev [unoptimized + debuginfo] target(s) in 0.93 secs
Running `target\debug\total.exe`
Hello, world!
>
Easy! We can jump straight to writing code in main.rs.
Reading configuration from the environment
The app is going to look for a particular property on events it receives. When we kick off the app in Seq, we’ll specify this through the UI. The value we provide will be passed to the app in an environment variable named using the pattern SEQ_APP_SETTING_*
, where *
is the setting name. In this case, the setting is called PROPERTYNAME
.
use std::env;
fn main() {
let property_name = env::var("SEQ_APP_SETTING_PROPERTYNAME")
.expect("Property name setting is required");
}
Apart from a warning about the unused property_name
variable, this is a valid Rust program. We can even set the environment up locally and run it to test that everything works.
Throughout the example, error handling shortcuts like expect()
are used: these will tear down the process if anything goes wrong, which is fine for our current purposes.
To tell Seq which settings are available, we need to package an app definition file alongside the executable. Here it is:
{
"name": "Total Example",
"description": "Keeps and prints a running total of a numeric property.",
"executable": "total.exe",
"settings": {
"propertyName": {
"displayName": "Property to total",
"helpText": "The name of a numeric property for the app to detect and report the total of."
}
}
}
Notice settings.propertyName
describes the SEQ_APP_SETTING_PROPERTYNAME
value we want the app to receive.
To get Seq to install the app we’ll need to bundle total.exe
and the definition file total.exe.d.json
up into a NuGet package for an app feed. I’ll spare you the rather dull process - the trivial build script and NuSpec file in the example’s GitHub repository can tell that story.
Installed into Seq, we see the details from the app definition file:
And when starting an instance, a text box is presented for the Property to total setting:
Don’t start an instance yet, though: the app needs to read events from its input to avoid queueing them up in memory.
Reading input events from STDIN
Once the app process is started, Seq will write events as JSON documents, one per line, to the app’s standard input. In .NET, this would mean receiving one event per Console.ReadLine()
call. We can iterate through these pretty naturally in Rust:
for input in stdin.lock().lines() {
let line = input.unwrap();
// Process the line...
}
The events themselves are in the compact JSON format written by Serilog.Formatting.Compact. A typical event might look like:
{"@t":"2017-08-25T14:55:32.456","@mt":"Dispensed {Volume} L","Volume":10.1}
The app can ignore details like the timestamp and message; we’re only interested in the value of a specific property like "Volume"
, and only when it’s a number.
Here’s the complete main()
method, excluding all of the boilerplate that you can see in the full source code:
fn main() {
let property_name = env::var("SEQ_APP_SETTING_PROPERTYNAME")
.expect("Property name setting is required");
let mut current_total: f64 = 0.;
let stdin = io::stdin();
emit_total(&property_name, current_total);
for input in stdin.lock().lines() {
let line = input.unwrap();
let data: Value = serde_json::from_str(&line).unwrap();
if let Some(f) = data.as_object()
.and_then(|o| o.get(&property_name))
.and_then(|n| n.as_f64()) {
current_total += f;
emit_total(&property_name, current_total);
}
}
}
Deserialization is handled by serde, which gets an entry in Cargo.toml. serde_json::Value
is the type used for arbitrary JSON data, and helpers like as_object()
, get()
and as_f64()
let us drill into the structure looking for the property of interest. (Thanks, Ashley Mannix, for helping to simplify this code.)
You’ll notice emit_total()
in there - called once when the app starts up, and then for each input event to write the running total back to Seq. That’s the next topic…
Writing output events to STDERR
Most Seq apps don’t write events back to Seq, or only do this to report problems. Our example is going to emit an event back to Seq reporting the total each time it is updated.
Since it can be hard to prevent some libraries writing junk to STDOUT
, Seq reads output events from STDERR
instead (arguably, STDERR
is the right place for diagnostic output anyway). The output format is the same as the input: compact JSON documents, one per line.
The example app is permissive about what it will accept as input, but we know that the output will always have precisely the same shape. Instead of using serde’s Value
type, we can define a struct
describing each output line:
#[derive(Serialize)]
struct CurrentTotalEvent<'a> {
#[serde(rename="@t")]
timestamp: DateTime<Utc>,
#[serde(rename="@mt")]
message_template: &'static str,
#[serde(rename="PropertyName")]
property_name: &'a str,
#[serde(rename="Total")]
total: f64
}
impl<'a> CurrentTotalEvent<'a> {
pub fn new<'b>(property_name: &'b str, total: f64) -> CurrentTotalEvent<'b> {
CurrentTotalEvent {
timestamp: Utc::now(),
message_template: CURRENT_TOTAL_IS,
property_name: property_name,
total: total
}
}
}
Each output event will carry the @t
timestamp (a chrono DateTime<Utc>
), a constant message template @mt
, the name of the property being totalled, and finally the total itself. Assuming you’re more familiar with C# than Rust (a fair bet, if you found this blog post :-)), nothing should be too surprising, here. The 'a
and 'b
lifetime parameters are just a roundabout way of saying that a CurrentTotalEvent
shouldn’t live any longer than the property_name
embedded in it. Lifetimes are a very interesting Rust feature; I had a shot at explaining them in this earlier post.
Emitting the total is a matter of constructing a CurrentTotalEvent
with all of these values, stringifying it, and printing the result to STDERR
:
const CURRENT_TOTAL_IS : &'static str = "The current total of {PropertyName} is {Total}";
fn emit_total<'a>(property_name: &'a str, total: f64) {
let evt = CurrentTotalEvent::new(property_name, total);
let json = serde_json::to_string(&evt).unwrap();
eprintln!("{}", json);
}
It took me a solid half an hour to write my first attempt at this code, once I’d installed Rust on my new development box. It’s a straightforward program, and the amount of code involved is pretty small. I then spent another half hour making the compiler happy - it’s been a while since I’ve written any Rust, so I guess my skills were a bit … rusty.
Interestingly, once I’d knocked off all of the (very well-explained) compiler errors, the app worked flawlessly on the very first run. Something I love about the Rust development experience! :-)
The app in action
Here’s what the event stream looks like, with the Volume
property being totalled by the app:
To run it yourself, you can grab the source and build the app package using Build.ps1
on the command-line. In Seq, add the folder containing the output .nupkg
file to Settings > Feeds and then install the app as Seq.App.Example.Total from Settings > Apps > Install from NuGet.
Summary
I expect that post-4.2, most Seq apps will still be written using the .NET assembly model that’s always been supported. I had fun testing the design via another language though, and I think having the option to process events using other languages will be appealing for those who use Seq in non-.NET environments. Happy hacking!