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.