Getting metrics out of Systemd Journal with Go

Leandro Santiago

November 26th 2024

My use case

Multiple embedded Linux systems “on the edge”, mostly written in C++ and logging to Systemd/Journal.

I want to get some metrics from the C++ written applications, using Prometheus, to feed Grafana dashboards.

I don’t want to embed a Prometheus client inside the application.

It’d require lots of changes which might affect especially the application performance and stability.

On the other hand, I can easily write prometheus exporters in Go without affecting the rest of the system.

The solution I found was to write an exporter that uses the application logs as input to build the metrics.

I cannot afford to send all the logs to a central place to be processed (logstash, etc.).

My systems often have unreliable and slow internet connection.

I cannot store metrics locally.

I use Prometheus Remote Write to send them to a remote server to aggregate the data.

I can though rely on the fact that Journal supports structured logging and attach arbitrary information to my log lines.

You could call them metadata or tags.

Why not Grok Exporter?

Grok Exporter is listed in the Prometheus integration page: https://github.com/fstab/grok_exporter

It seems to be a “tail -f” on steroids, not leveraging Journal power.

Although I haven’t benchmarked, I suspect it does not perform well under high number of different log patterns, as they need to be matched on every log line.

I might be mistaken, though!

Grok Exporter comes with a large library of grok patterns, widely used in the industry, but they are not useful for me, as I am working with a custom application.

I can survive with simple Regexps instead of the fancy patterns grok offers.

Grok Exporter is quite big and mature.

--------------------------------------------------
 Language      Files  Lines Blank  Comment   Code
--------------------------------------------------
 Go               65  12027  1027     2858   8142
 Markdown          7   1723   413        0   1310
 Bourne Shell      3    549    76       52    421
 YAML              3    121    10       42     69
 C                 1     52     7       20     25
 C/C++ Header      1     20     2       13      5
--------------------------------------------------
 Total            80  14492  1535     2985   9972
--------------------------------------------------

Finally, it describes itself as “Export Prometheus metrics from arbitrary unstructured log data”, whereas my logs are “semi-structured”.

Go Journald Exporter is born

It is young and small.

------------------------------------------------
 Language  Files   Lines  Blank  Comment   Code
------------------------------------------------
 Go            4     617    126        7    484
 Markdown      1     122     37        0     85
 YAML          1      34      4       10     20
------------------------------------------------
 Total         6     773    167       17    589
------------------------------------------------

C++ side

In my C++ application, for each log statement, I also create a tag, so the log statements look like this:

const auto time = 34;
const auto command = "blah";
LOG("command_time") << "It took " << time
                    << "ms to run command" << command;

Where “command_time” is application defined.

C API

struct iovec iov[2];

// MESSAGE= is mandatory
iov[0].iov_base = "MESSAGE=It took 34ms to run command blah";
iov[0].iov_len = 40;

// Arbitrarily add optional fields
iov[1].iov_base = "MY_LOG_CLASS=command_time";
iov[1].iov_len = 22;

sd_journal_sendv(iov, sizeof(iov) / sizeof(struct iovec));

The log library will speak to Journal directly instead via stdout/stderr.

The main advantage of it is using the structured log properties of Journal.

Semi-structured logging

My C++ codebase is quite old so I want to use the existing log statements.

import logging
from systemd import journal

journal_handler = journal.JournalHandler()
formatter = logging.Formatter('%(message)s')
journal_handler.setFormatter(formatter)

logger = logging.getLogger('MyApp')
logger.addHandler(journal_handler)
logger.setLevel(logging.DEBUG)
logger.info(
    f"It took {time}ms to run command {command}",
    extra={"MY_LOG_CLASS": "command_time"})

What Journal sees

Output of journalctl -o json

{
  "MY_LOG_CLASS": "command_time",
  "MESSAGE": "It took 34ms to run command blah",
  "CODE_FUNC": "main",
  "CODE_FILE": "test.cpp",
  "CODE_LINE": "13",
  "SYSLOG_IDENTIFIER": "test",
  "BLAH": "bazilions of other properties here..."
}

Basic usage

And, in its basic usage, with:

go-journald-exporter \
  -field MY_LOG_CLASS \
  -filter SYSLOG_IDENTIFIER=test

It will connect to the user session, if run as a normal user, or to the system session, if executed as root or any user in the systemd-journal group.

Using a configuration file

A better way is using a configuration file:

go-journald-exporter -config config.yml

Simplest config.yml

listen_address: 127.0.0.1:9999
fields: # what labels will be included by default in all metrics
  - MY_LOG_CLASS
filters: # only logs with such fields will be "watched"
  - SYSLOG_IDENTIFIER=test

It will do it to simply create metrics for each distinct member of the fields list.

That was my start point on development.

Generated metrics

# HELP go_journald_exporter_without_unit 
# TYPE go_journald_exporter_without_unit counter
go_journald_exporter_without_unit{field="MY_LOG_CLASS",value="command_time"} 1

More powerful metrics

By using a configuration file, you can enable more powerful metrics by using custom patterns.

Histograms

histograms: # a list of histograms
  - name: command_time # metric name
    # all named groups will become extra labels in the metric.
    # A unique label called `value`, that can be parsed
    # by `strconv.ParseFloat()`, is mandatory on histograms!
    pattern: ^It took (?P<value>\d+)ms to run command (?P<command>\w+)$
    buckets:
      type: exponential # linear or exponential?
      min: 1
      max: 100
      count: 5
    condition:
      field: MY_LOG_CLASS
      value: command_time

Histograms

# HELP go_journald_exporter_command_time 
# TYPE go_journald_exporter_command_time histogram
go_journald_exporter_command_time_bucket{MY_LOG_CLASS="command_time",command="blah",le="1"} 0
go_journald_exporter_command_time_bucket{MY_LOG_CLASS="command_time",command="blah",le="3.1622776601683795"} 0
go_journald_exporter_command_time_bucket{MY_LOG_CLASS="command_time",command="blah",le="10.000000000000002"} 0
go_journald_exporter_command_time_bucket{MY_LOG_CLASS="command_time",command="blah",le="31.6227766016838"} 0
go_journald_exporter_command_time_bucket{MY_LOG_CLASS="command_time",command="blah",le="100.00000000000004"} 1
go_journald_exporter_command_time_bucket{MY_LOG_CLASS="command_time",command="blah",le="+Inf"} 1
go_journald_exporter_command_time_sum{MY_LOG_CLASS="command_time",command="blah"} 34
go_journald_exporter_command_time_count{MY_LOG_CLASS="command_time",command="blah"} 1

Custom counters

counters: # a list of counters
  - name: command_time # metric name
    # all named groups will become extra labels
    # in the metric.
    # I should not use the `time` in the metric,
    # otherwise the number of metrics might explode,
    # causing serious performance issues!
    pattern: ^It took (\d+)ms to run command (?P<command>\w+)$
    condition:
      field: MY_LOG_CLASS
      value: command_time

Custom counters

(Note the extra command label)

# HELP go_journald_exporter_command_time 
# TYPE go_journald_exporter_command_time counter
go_journald_exporter_command_time{MY_LOG_CLASS="command_time",command="blah"} 1

Metrics

You can of course have multiple counters and histograms in the same configuration file.

Disable default counters

If you want to disable the default automatically generated counters, add to your config file:

disable_default_counters: true

How to install it?

Requirements:

  • Go compiler (1.22 or newer)
  • Systemd headers (libsystemd-dev on Debian/Ubuntu)
go install gitlab.com/leandrosansilva/go-journald-exporter@latest

Future plans:

  • Support metrics descriptions
  • Performance analysis and profiling to find performance issues
  • Implement support for other metric types (gauge and summary)
  • Improve test coverage

Future plans:

  • Provide pre-built binaries/docker image?
  • Proper release management
  • benchmark go-journald-exporter against grok_exporter.
  • RiiR (just joking, although it could be an interesting exercise)

Any questions?

Leandro Santiago

social@setefaces.org

(BTW, I am open to new opportunities. Talk to me!)