Unified Logging for Multi-Region Rails with Navigator, Vector, and NATS
The showcase application runs across three environments: 8 Fly.io machines globally, a Hetzner bare metal server in Helsinki, and a Mac Mini at my home in Raleigh. Each environment has different constraints, but all need the same thing: a unified way to view logs.
Fly.io uses Vector internally and provides NATS access as a built-in feature. All logs from Fly.io machines automatically flow to NATS subjects that you can subscribe to. No additional configuration needed—it just works via fly logs and NATS proxy.
Hetzner setup is already documented in Monitoring with Vector and NATS. Vector collects Docker logs and forwards them to Fly.io's NATS instance, unifying logs across both clouds.
This post focuses on completing the picture by adding logging to rubymini, bringing the home development/admin server into the same unified logging system.
Overall Design
1. NATS as Universal Broker
NATS is a lightweight, cloud-native messaging system that's perfect for log distribution:
- ✅ Fly.io provides NATS as a built-in service (no setup needed)
- ✅ Publish-subscribe model fits log streaming naturally
- ✅ Subject-based routing enables filtering (by app, region, machine)
- ✅ Lightweight clients available for Go, Ruby, JavaScript
For Fly.io machines, logs flow directly to Fly's NATS instance. For Hetzner and rubymini, Vector forwards logs to NATS.
2. Vector for Log Collection
Vector is a high-performance observability pipeline that collects, transforms, and routes logs:
- ✅ Built into Fly.io (used internally for all log collection)
- ✅ Integrates directly with Navigator via Unix sockets
- ✅ Supports multiple sinks (NATS, files, S3, etc.)
- ✅ Filters and transforms logs before forwarding
- ✅ Battle-tested across millions of deployments
For Fly.io, Vector is already running—you just consume from NATS. For rubymini, Navigator's built-in Vector integration means zero external configuration—Navigator starts and manages the Vector process.
3. Bun Logger App for Viewing
A simple web application provides the viewing interface:
- ✅ Subscribes to NATS subjects (e.g.,
logs.*) - ✅ WebSocket connection streams logs to browser
- ✅ Client-side filtering and search
- ✅ Lightweight (Bun runtime, minimal dependencies)
The logger app runs as a separate Fly.io application for global access, plus locally on rubymini for development/debugging.
Implementation: Mac Mini (rubymini)
The Mac Mini runs Navigator via launchd, with logs forwarded to a local NATS instance for development.
NATS Installation
Install NATS server via Homebrew:
brew install nats-server
Create launchd plist for NATS:
<!-- ~/Library/LaunchAgents/showcase-nats.plist -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>net.intertwingly.showcase-nats</string>
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/bin/nats-server</string>
<string>--config</string>
<string>/Users/rubys/showcase-nats.conf</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/Users/rubys/Library/Logs/showcase-nats.log</string>
<key>StandardErrorPath</key>
<string>/Users/rubys/Library/Logs/showcase-nats.log</string>
</dict>
</plist>
Load the service:
launchctl load ~/Library/LaunchAgents/showcase-nats.plist
Navigator Vector Integration
Navigator automatically starts and manages Vector when enabled in the configuration. The application generates config/navigator.yml dynamically with:
logging:
format: json
vector:
enabled: true
socket: /tmp/navigator-vector.sock
config: /Users/rubys/git/showcase/config/vector.toml
When vector.enabled is true, Navigator automatically:
- Starts Vector as a high-priority managed process
- Streams logs to Vector via Unix socket
- Cleans up stale Unix sockets on startup (prevents "address already in use" errors)
- Manages Vector lifecycle (restart on crash, stop on shutdown)
Create config/vector.toml to configure Vector's log forwarding:
# Global configuration
data_dir = "/Users/rubys/git/showcase/log/vector"
# Receive logs from Navigator via Unix socket
[sources.navigator]
type = "socket"
mode = "unix"
path = "/tmp/navigator-vector.sock"
# Parse and wrap JSON logs from Navigator
[transforms.parse_json]
type = "remap"
inputs = ["navigator"]
source = '''
# Parse the JSON message from Navigator
original = parse_json!(string!(.message))
# Add source field if missing (for HTTP access logs)
if !exists(original.source) {
if exists(original.client_ip) {
original.source = "access"
} else {
original.source = "unknown"
}
}
# Wrap in logfiler-expected format
.timestamp = original."@timestamp"
.message = encode_json(original)
.log = {"level": "info"}
.fly = {
"app": {
"name": "showcase",
"instance": "rubymini"
},
"region": "rdu"
}
.source = original.source
'''
# Forward to NATS
[sinks.nats]
type = "nats"
inputs = ["parse_json"]
url = "nats://localhost:4222"
subject = "logs.showcase.rubymini."
encoding.codec = "json"
The transform does two things:
- Adds source identification - Navigator access logs don't have a
sourcefield, so Vector addssource="access"for HTTP logs - Wraps in logfiler format - The logger app expects logs in a specific structure with
timestamp,message,fly.region, andlog.levelfields. Navigator's pure JSON logs need to be wrapped in this format.
Navigator now forwards logs to the local NATS instance, making them available to the logger app.
Logger App Deployment
Deploy the Bun logger app as a launchd service:
<!-- ~/Library/LaunchAgents/showcase-logger.plist -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>net.intertwingly.showcase-logger</string>
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/bin/bun</string>
<string>run</string>
<string>/Users/rubys/showcase-logger/index.js</string>
</array>
<key>WorkingDirectory</key>
<string>/Users/rubys/showcase-logger</string>
<key>EnvironmentVariables</key>
<dict>
<key>PORT</key>
<string>9001</string>
<key>NATS_URL</key>
<string>nats://localhost:4222</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/Users/rubys/Library/Logs/showcase-logger.log</string>
<key>StandardErrorPath</key>
<string>/Users/rubys/Library/Logs/showcase-logger.log</string>
</dict>
</plist>
Load the service:
launchctl load ~/Library/LaunchAgents/showcase-logger.plist
The logger can be accessed two ways:
- Direct access for local debugging:
http://localhost:9001/ - Via reverse proxy for normal use:
https://rubix.intertwingly.net/showcase/logs(Navigator automatically proxies tolocalhost:9001)
Logger Application
The logger app is a TypeScript/Bun application that subscribes to NATS and streams logs to a web interface. The actual implementation is more sophisticated than shown here, with separate modules for log filtering, formatting, WebSocket broadcasting, and Sentry integration.
Below is a simplified conceptual example showing the core pattern. For the complete implementation, see the showcase repository.
Backend (Simplified Example)
import { connect } from 'nats';
import { serve } from 'bun';
const nats = await connect({
servers: process.env.NATS_URL || 'nats://localhost:4222'
});
// WebSocket clients
const clients = new Set();
// Subscribe to all showcase logs
const sub = nats.subscribe('logs.showcase.*.*');
(async () => {
for await (const msg of sub) {
const log = JSON.parse(msg.data);
// Broadcast to all connected WebSocket clients
for (const client of clients) {
client.send(JSON.stringify(log));
}
}
})();
// Web server
serve({
port: process.env.PORT || 9001,
fetch(req, server) {
const url = new URL(req.url);
if (url.pathname === '/logs') {
// Serve HTML viewer
return new Response(HTML_VIEWER, {
headers: { 'Content-Type': 'text/html' }
});
}
if (url.pathname === '/ws') {
// Upgrade to WebSocket
if (server.upgrade(req)) {
return; // WebSocket upgraded
}
return new Response('WebSocket upgrade failed', { status: 400 });
}
return new Response('Not found', { status: 404 });
},
websocket: {
open(ws) {
clients.add(ws);
},
close(ws) {
clients.delete(ws);
}
}
});
Frontend (HTML Viewer)
<!DOCTYPE html>
<html>
<head>
<title>Showcase Logs</title>
<style>
body { font-family: monospace; padding: 20px; background: #1e1e1e; color: #d4d4d4; }
#logs { white-space: pre-wrap; }
.error { color: #f44747; }
.warn { color: #ff8c00; }
.info { color: #4ec9b0; }
.debug { color: #858585; }
input { width: 400px; padding: 5px; margin: 10px 0; }
</style>
</head>
<body>
<h1>Showcase Logs</h1>
<input type="text" id="filter" placeholder="Filter logs (regex)..." />
<button onclick="clearLogs()">Clear</button>
<div id="logs"></div>
<script>
const ws = new WebSocket('ws://' + location.host + '/ws');
const logsDiv = document.getElementById('logs');
const filterInput = document.getElementById('filter');
ws.onmessage = (event) => {
const log = JSON.parse(event.data);
// Apply filter
const filter = filterInput.value;
if (filter && !new RegExp(filter).test(JSON.stringify(log))) {
return;
}
// Format log entry
const time = new Date(log.timestamp).toLocaleTimeString();
const level = log.level || 'info';
const message = log.message || JSON.stringify(log);
const line = document.createElement('div');
line.className = level;
line.textContent = `[${time}] [${log.app || 'unknown'}] ${message}`;
logsDiv.appendChild(line);
// Auto-scroll to bottom
logsDiv.scrollTop = logsDiv.scrollHeight;
// Limit to 1000 lines
while (logsDiv.children.length > 1000) {
logsDiv.removeChild(logsDiv.firstChild);
}
};
function clearLogs() {
logsDiv.innerHTML = '';
}
</script>
</body>
</html>
Features
- Real-time streaming: Logs appear as they're published to NATS
- Client-side filtering: Regex filtering without server load
- Auto-scroll: Always shows most recent logs
- Color coding: Different colors for error/warn/info/debug levels
- Lightweight: Entire app is <200 lines of code
Try It Yourself
All components are open source:
- Navigator: github.com/rubys/navigator - Reverse proxy with Vector integration
- Vector: vector.dev - High-performance log pipeline
- NATS: nats.io - Lightweight message broker
- Logger app: showcase/fly/applications/logger - Bun/TypeScript log viewer
Conclusion
Distributed applications need centralized logs. When your Rails app runs across Fly.io, Hetzner, and a Mac Mini at home, unified logging eliminates context-switching and speeds up debugging.
The pattern is straightforward:
- NATS as universal broker - Publish logs from all sources
- Vector for collection - Navigator integration makes it zero-config
- Simple web viewer - Real-time streaming to browser
The result is unified logging without operational complexity: no vendor lock-in, no complex configuration, no logging fees.
Start simple. Add Vector to Navigator. Deploy NATS and logger. View logs from all environments in one interface.
Debugging multi-region issues shouldn't require juggling three log systems—it should just require opening a browser.
For the broader architectural context of showcase's multi-region deployment, see Shared-Nothing Multi-Tenancy with SQLite, TurboCable, and Navigator.
For complete implementation details including launchd plists, Vector configuration templates, and logger app source code, see the showcase repository.