Logging from Perl to macOS’ unified log with FFI and Log::Any

Part 1: The elephant in the room

A few weeks ago, I start­ed host­ing my own Mastodon instance on a Mac mini in my home office. I want­ed to join the social Fediverse on my own terms–but it did­n’t take long to notice bal­loon­ing disk usage. Cached media from oth­er users’ posts was pil­ing up fast.

That got me think­ing: how do I track this growth before it gets out of hand?

Logging seemed like the obvi­ous answer. On Unix and Linux sys­tems, it’s straight­for­ward enough. But on macOS, find­ing a native, main­tain­able solu­tion takes more digging.

Part 2: Feeding the Apple

macOS is Unix-​based, so you’d expect log­ging to be sim­ple. You can install logro­tate via Homebrew, then sched­ule it with cron(8). It works–but it adds lay­ers of con­fig­u­ra­tion files, per­mis­sions, and guess­work. I want­ed some­thing native. Something that felt like it belonged on a Mac.

Turns out, macOS offers two built-​in options. One is newsys­log, a BSD-​style tool that rotates logs based on size or time. It’s reli­able, but it requires priv­i­leged root-owned con­fig­u­ra­tion files and feels like a holdover from old­er Unix systems.

The oth­er is Apple’s uni­fied log­ging sys­tem–a mod­ern API used across macOS, iOS, and even watchOS. It’s struc­tured, search­able, and already baked into the plat­form. That’s the one I decid­ed to explore.

Howard Oakley’s explain­er on the Unified Log helped me under­stand Apple’s sys­tem for con­sol­i­dat­ing logs. It showed how they are stored in a com­pressed bina­ry for­mat, com­plete with struc­tured meta­da­ta and pri­va­cy con­trols. With that foun­da­tion, I turned to Apple’s OSLog Framework doc­u­men­ta­tion. It showed how to tag entries and fil­ter them with pred­i­cates. macOS han­dles the rest.

It’s elegant–but you need to use the API to write logs. Yes, read­ing and fil­ter­ing can be done on the com­mand line or in the Console app. But Apple seems to expect log­ging to be the sole province of Swift and Objective‑C devel­op­ers. I’d rather not have to learn a new pro­gram­ming lan­guage just to write logs.

UPDATE: Howard Oakley’s blow­hole util­i­ty pro­vides a sim­ple way to write to the uni­fied log from the com­mand line, but all mes­sages come from the ​“co.eclecticlight.blowhole” sub­sys­tem with a ​“gen­er­al” cat­e­go­ry. We can do better.

Part 3: A platypus in the key of C

I do know Perl. I also know just enough C to be dan­ger­ous. And I briefly con­sid­ered learn­ing Swift or Objective‑C. Nevertheless, I won­dered about bridg­ing Perl to Apple’s uni­fied log­ging sys­tem with­out switch­ing languages.

macOS expos­es a C API in <os/log.h>:

#include <os/log.h>

void
os_log(os_log_t log, const char *format, ...);

void
os_log_info(os_log_t log, const char *format, ...);

void
os_log_debug(os_log_t log, const char *format, ...);

void
os_log_error(os_log_t log, const char *format, ...);

void
os_log_fault(os_log_t log, const char *format, ...);

Perl’s CPAN has a mod­ule called FFI::Platypus that would let me call for­eign func­tions in C and oth­er lan­guages. It looked promising.

But there’s a catch: these log­ging func­tions are vari­adic macros, not plain func­tions. That makes them inac­ces­si­ble via FFI. Worse, they expand into pri­vate API calls–unstable across OS updates and risky to rely upon.

So I wrote a small C wrap­per to con­vert each macro into a prop­er func­tion. This makes them FFI-​safe and lets me con­trol vis­i­bil­i­ty (pub­lic log­ging vs. pri­vate, redact­ed log­ging) using Apple’s for­mat specifiers:

#include <os/log.h>

#define DEFINE_OSLOG_WRAPPERS(level_macro, suffix)    \
    void os_log_##suffix##_public(os_log_t log,       \
                                  const char *msg) {  \
        level_macro(log, "%{public}s", msg);          \
    }                                                 \
    void os_log_##suffix##_private(os_log_t log,      \
                                   const char *msg) { \
        level_macro(log, "%{private}s", msg);         \
    }

// Generate wrappers for each log level
DEFINE_OSLOG_WRAPPERS(os_log, default)
DEFINE_OSLOG_WRAPPERS(os_log_info, info)
DEFINE_OSLOG_WRAPPERS(os_log_debug, debug)
DEFINE_OSLOG_WRAPPERS(os_log_error, error)
DEFINE_OSLOG_WRAPPERS(os_log_fault, fault)

This macro gen­er­ates two func­tions per log level–one pub­lic, one private–giving down­stream Perl code a choice. It’s ver­bose, but it’s safe, auditable, and future-proof.

Part 4: Plugging into Log::Any

With the wrap­per library in place, I began map­ping Apple’s log lev­els to some­thing Perl can use. I chose Log::Any from CPAN because it’s light­weight, wide­ly sup­port­ed, and its adapters don’t lock you into a spe­cif­ic back-​end. The same code that logs to the screen can also log to a file, or in our case, Apple’s system.

Admittedly, at this point I’m no longer writ­ing a sim­ple log­ging script for my Mastodon instance. Instead, it’s a full-​fledged log­ging mod­ule. Oh well.

Some Log::Any lev­els share the same under­ly­ing Apple call– OSLog does­n’t dis­tin­guish between notice and info or trace and debug. That’s a lit­tle dif­fer­ent from how Unix sys­log does things, but that’s fine. The goal here is com­pat­i­bil­i­ty, not per­fect fidelity.

Building a sim­ple dis­patch table to route log mes­sages based on lev­el, I then used FFI::Platypus to bind each wrap­per function:

use FFI::Platypus 2.00;

my %OS_LOG_MAP = (
    trace     => 'os_log_debug',
    debug     => 'os_log_debug',
    info      => 'os_log_info',
    notice    => 'os_log_info',
    warning   => 'os_log_fault',
    error     => 'os_log_error',
    critical  => 'os_log_default',
    alert     => 'os_log_default',
    emergency => 'os_log_default',
);

my $ffi = FFI::Platypus->new(
    api => 2,
    lib => [ './liboslogwrapper.dylib' ],
);

$ffi->attach(
    [ os_log_create => '_os_log_create' ],
    [ 'string', 'string' ],
    'opaque',
);

# attach each wrapper function
my %UNIQUE_OS_LOG = map { $_ => 1 } values %OS_LOG_MAP;
foreach my $function ( keys %UNIQUE_OS_LOG ) {
    for my $variant (qw(public private)) {
        my $name = "${function}_$variant";
        $ffi->attach(
            [ $name => "_$name" ],
            [ 'opaque', 'string' ],
            'void',
        );
    }
}

This set­up gives me a clean way to log from Perl using Apple’s native sys­tem. I can achieve this with­out touch­ing Swift, Objective‑C, or exter­nal tools. Each log lev­el maps to a C wrap­per, and the FFI lay­er han­dles the rest.

Now I just need an init func­tion to cre­ate the os_​log_​t object and a set of meth­ods for log­ging and detect­ing whether a giv­en log lev­el is enabled:

use strict;
use Carp;
use base qw(Log::Any::Adapter::Base);
use Log::Any::Adapter::Util qw(
  detection_methods
  numeric_level
);

sub init {
    my $self = shift;
    $self->{private} ||= 0;
    croak 'subsystem is required'
      unless defined $self->{subsystem};

    $self->{_os_log} = _os_log_create(
      @{$self}{qw(subsystem category)},
    );

    return;
}

foreach my $log_level ( keys %OS_LOG_MAP ) {
    no strict 'refs';
    *{$log_level} = sub {
        my ( $self, $message ) = @_;

        &{  "_$OS_LOG_MAP{$log_level}_"
                . ( $self->{private}
                    ? 'private'
                    : 'public'
                ) }( $self->{_os_log}, $message );
    };
}

foreach my $method ( detection_methods() ) {
    my $method_level = numeric_level(substr $method 3);
    no strict 'refs';
    *{$method} = sub {
        !!( $method_level <= (
          $_[0]->{log_level} // numeric_level('info')
        ) );
    };
}

What’s that ​“sub­sys­tem” bit up there? That’s the term macOS uses for iden­ti­fy­ing process­es in logs. They’re usu­al­ly for­mat­ted in reverse DNS nota­tion (e.g., ​“com.example.perl”). Once again, Howard Oakley has a great explain­er on the top­ic.

Also, there’s some metapro­gram­ming going on there:

  • The first fore­ach loop cre­ates func­tions called trace, debug, and info. These func­tions call the cor­re­spond­ing FFI::Platypus-created func­tions. It uses the pri­vate vari­ants if the pri­vate attribute for the log adapter was set.
  • The sec­ond fore­ach loop cre­ates cre­ates func­tions called is_​trace, is_​debug, is_​info, etc., that return true if the adapter is catch­ing that lev­el of log message.

Part 5: At long last, logging… mostly

Once this is pack­aged in a Perl mod­ule, how do you use it? At least that part isn’t too hard:

use Log::Any '$log', default_adapter => [
  'MacOS::OSLog', subsystem => 'com.phoenixtrap.perl',
];
use English;
use Carp qw(longmess);

$log->info('Hello from Perl!');
$log->infof('You are using Perl %s', $PERL_VERSION);

$log->trace( longmess('tracing!') );
$log->debug(     'debugging!'     );
$log->info(      'informing!'     );
$log->notice(    'noticing!'      );
$log->warning(   'warning!'       );
$log->error(     'erring!'        );
$log->critical(  'critiquing!'    );
$log->alert(     'alerting!'      );
$log->emergency( 'emerging!'      );

And then you can run this com­mand line to stream log mes­sages from the sub­sytem used above:

% log stream --level debug \
  --predicate 'subsystem == "com.phoenixtrap.perl"

What hap­pened to the trace and debug log mes­sages that were sup­posed to call os_log_debug(3)? According to macOS’ log(1) man­u­al page, you have to explic­it­ly allow debug­ging out­put for a giv­en subsystem:

% sudo log config --mode "level:debug" \
  --subsystem com.phoenixtrap.perl

Et voilà!

Hmm, same lack of debug­ging messages.

I’m still fig­ur­ing this out. Any clues? Drop me a line!

UPDATE: This is now fixed thanks to some inspi­ra­tion from the source code of Log::Any::Adapter::Syslog. I’ve updat­ed the code on Codeberg; here is the diff.

Bonus: Fancy output

Thanks to Log::Any::Proxy, you also get sprintf for­mat­ting vari­ant functions:

use English;
$log->infof(
    'You are using Perl %s in %d',
    $PERL_VERSION, (localtime)[5] + 1900,
);
You are using Perl v5.40.2 in 2025

If you out­put an object that over­loads string rep­re­sen­ta­tion, you get that string:

use DateTime;
$log->infof('It is now %s', DateTime->now);
It is now 2025-08-10T20:16:50

And you get single-​line Data::Dumper out­put of com­plex data struc­tures, plus replac­ing unde­fined val­ues with the string ​“undef”:

$log->info( {
    foo    => 'hello',
    bar    => 'world',
    colors => [ qw(
        red
        green
        blue
    ) ],
    null => undef,
} );
{bar => "world",colors => ["red","green","blue"],foo => "hello",null => undef}

Conclusion: Build once, use everywhere

The best tools aren’t always the ones you planned to build. They’re the ones that solve a prob­lem cleanly–and then solve five more you hadn’t thought of yet.

What start­ed as a quick fix for Mastodon media mon­i­tor­ing became a reusable bridge between Perl and macOS’ Unified Log. Along the way, I got to explore Apple’s log­ging inter­nals, write an FFI-​respecting C wrap­per, and inte­grate clean­ly with Log::Any. The result­ing code is mod­u­lar, auditable, and–most importantly–maintainable.

I did­n’t set out to write a log­ging adapter. But when you care about clean ops and repro­ducible infra­struc­ture, some­times the best tools are the ones you build your­self. And if they hap­pen to be over-​engineered for the task at hand? All the better–they’ll prob­a­bly out­live it.

Try it out or contribute!

The full adapter code is on Codeberg. If you’re log­ging from Perl on macOS, give it a spin. Contributions, bug reports, and real-​world feed­back are welcome–especially if you’re test­ing it in pro­duc­tion or on old­er macOS versions.

I’ll do my best to stay com­pat­i­ble with past and future macOS and Perl releas­es. Keeping the code auditable and min­i­mal should help it stay use­ful with­out becom­ing a mov­ing target.


Discover more from The Phoenix Trap

Subscribe to get the latest posts sent to your email.

Mark Gardner Avatar

Hi, I’m Mark.

Hi, I’m Mark Gard­ner, and this is my personal blog. I show software developers how to level up by building production-ready things that work. Clear code, real projects, lessons learned.

Your post must include a link to this post.

How does this work?
Write a response on your own website and include a link to this post. Then submit your response’s URL here. Your response may appear here after moderation. To update or remove it, edit or delete your original post and submit the same URL again. Learn more about Webmentions.