Part 1: The elephant in the room
A few weeks ago, I started hosting my own Mastodon instance on a Mac mini in my home office. I wanted to join the social Fediverse on my own terms–but it didn’t take long to notice ballooning disk usage. Cached media from other users’ posts was piling up fast.
That got me thinking: how do I track this growth before it gets out of hand?
Logging seemed like the obvious answer. On Unix and Linux systems, it’s straightforward enough. But on macOS, finding a native, maintainable solution takes more digging.
Part 2: Feeding the Apple
macOS is Unix-based, so you’d expect logging to be simple. You can install logrotate via Homebrew, then schedule it with cron(8). It works–but it adds layers of configuration files, permissions, and guesswork. I wanted something native. Something that felt like it belonged on a Mac.
Turns out, macOS offers two built-in options. One is newsyslog, a BSD-style tool that rotates logs based on size or time. It’s reliable, but it requires privileged root-owned configuration files and feels like a holdover from older Unix systems.
The other is Apple’s unified logging system–a modern API used across macOS, iOS, and even watchOS. It’s structured, searchable, and already baked into the platform. That’s the one I decided to explore.
Howard Oakley’s explainer on the Unified Log helped me understand Apple’s system for consolidating logs. It showed how they are stored in a compressed binary format, complete with structured metadata and privacy controls. With that foundation, I turned to Apple’s OSLog Framework documentation. It showed how to tag entries and filter them with predicates. macOS handles the rest.
It’s elegant–but you need to use the API to write logs. Yes, reading and filtering can be done on the command line or in the Console app. But Apple seems to expect logging to be the sole province of Swift and Objective‑C developers. I’d rather not have to learn a new programming language just to write logs.
UPDATE: Howard Oakley’s blowhole utility provides a simple way to write to the unified log from the command line, but all messages come from the “co.eclecticlight.blowhole” subsystem with a “general” category. 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 dangerous. And I briefly considered learning Swift or Objective‑C. Nevertheless, I wondered about bridging Perl to Apple’s unified logging system without switching languages.
macOS exposes 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 module called FFI::Platypus that would let me call foreign functions in C and other languages. It looked promising.
But there’s a catch: these logging functions are variadic macros, not plain functions. That makes them inaccessible via FFI. Worse, they expand into private API calls–unstable across OS updates and risky to rely upon.
So I wrote a small C wrapper to convert each macro into a proper function. This makes them FFI-safe and lets me control visibility (public logging vs. private, redacted logging) using Apple’s format 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 generates two functions per log level–one public, one private–giving downstream Perl code a choice. It’s verbose, but it’s safe, auditable, and future-proof.
Part 4: Plugging into Log::Any
With the wrapper library in place, I began mapping Apple’s log levels to something Perl can use. I chose Log::Any from CPAN because it’s lightweight, widely supported, and its adapters don’t lock you into a specific 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 writing a simple logging script for my Mastodon instance. Instead, it’s a full-fledged logging module. Oh well.
Some Log::Any levels share the same underlying Apple call– OSLog doesn’t distinguish between notice and info or trace and debug. That’s a little different from how Unix syslog does things, but that’s fine. The goal here is compatibility, not perfect fidelity.
Building a simple dispatch table to route log messages based on level, I then used FFI::Platypus to bind each wrapper 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 setup gives me a clean way to log from Perl using Apple’s native system. I can achieve this without touching Swift, Objective‑C, or external tools. Each log level maps to a C wrapper, and the FFI layer handles the rest.
Now I just need an init function to create the os_log_t object and a set of methods for logging and detecting whether a given log level 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 “subsystem” bit up there? That’s the term macOS uses for identifying processes in logs. They’re usually formatted in reverse DNS notation (e.g., “com.example.perl”). Once again, Howard Oakley has a great explainer on the topic.
Also, there’s some metaprogramming going on there:
- The first foreach loop creates functions called trace, debug, and info. These functions call the corresponding FFI::Platypus-created functions. It uses the private variants if the private attribute for the log adapter was set.
- The second foreach loop creates creates functions called is_trace, is_debug, is_info, etc., that return true if the adapter is catching that level of log message.
Part 5: At long last, logging… mostly
Once this is packaged in a Perl module, 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 command line to stream log messages from the subsytem used above:
% log stream --level debug \
--predicate 'subsystem == "com.phoenixtrap.perl"

What happened to the trace and debug log messages that were supposed to call os_log_debug(3)? According to macOS’ log(1) manual page, you have to explicitly allow debugging output for a given subsystem:
% sudo log config --mode "level:debug" \
--subsystem com.phoenixtrap.perl
Et voilà!

Hmm, same lack of debugging messages.
I’m still figuring this out. Any clues? Drop me a line!
UPDATE: This is now fixed thanks to some inspiration from the source code of Log::Any::Adapter::Syslog. I’ve updated the code on Codeberg; here is the diff.
Bonus: Fancy output
Thanks to Log::Any::Proxy, you also get sprintf formatting variant 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 output an object that overloads string representation, 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 output of complex data structures, plus replacing undefined values 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 problem cleanly–and then solve five more you hadn’t thought of yet.
What started as a quick fix for Mastodon media monitoring became a reusable bridge between Perl and macOS’ Unified Log. Along the way, I got to explore Apple’s logging internals, write an FFI-respecting C wrapper, and integrate cleanly with Log::Any. The resulting code is modular, auditable, and–most importantly–maintainable.
I didn’t set out to write a logging adapter. But when you care about clean ops and reproducible infrastructure, sometimes the best tools are the ones you build yourself. And if they happen to be over-engineered for the task at hand? All the better–they’ll probably outlive it.
Try it out or contribute!
The full adapter code is on Codeberg. If you’re logging from Perl on macOS, give it a spin. Contributions, bug reports, and real-world feedback are welcome–especially if you’re testing it in production or on older macOS versions.
I’ll do my best to stay compatible with past and future macOS and Perl releases. Keeping the code auditable and minimal should help it stay useful without becoming a moving target.


