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.

