How to Monitor Heroku Logs: A Step-by-Step Guide for Developers

Heroku simplifies app deployment, letting you launch a Python script, Node.js API, or PHP site in minutes. But once your app is live, monitoring its behavior can be tricky. Are your dynos crashing? Is an API call slowing down? Heroku’s Logplex provides real-time logs via heroku logs --tail, but it’s limited—capping at 1,500 lines or one week of retention. For deeper debugging or historical analysis, you need a better solution. Enter OpenObserve, a scalable, cost-effective platform that captures every log type in a structured format, empowering you with real-time insights, dashboards, and alerts.
In this step-by-step guide, we’ll walk you through setting up Heroku, deploying a Node.js app to generate diverse logs, creating a forwarding app to parse and ingest those logs into OpenObserve Cloud, and troubleshooting to ensure everything works seamlessly. Whether you’re a developer debugging a production issue or a DevOps engineer monitoring app health, this guide will help you unlock the full potential of your Heroku logs.
Heroku’s Logplex aggregates logs from your app (stdout/stderr), router (HTTP requests), and system events (dyno restarts) into a single stream. It’s handy for quick checks—like spotting status codes or basic errors—but it falls short when you need deeper analysis. Let’s break down the challenges with Logplex and see how OpenObserve overcomes them.
Aspect | Heroku Logplex Limitations | OpenObserve Benefits |
---|---|---|
Retention | Capped at 1,500 lines or one week. | Unlimited retention, enabling long-term historical analysis. |
Log Structure | Raw text, hard to filter or analyze. | Structured JSON logs for easy querying and filtering (e.g., source:app). |
Historical Insights | No querying beyond the retention limit. | Query logs anytime with a fast engine, uncovering trends and past issues. |
Cost Efficiency | Not optimized for large-scale storage. | Columnar storage cuts costs by up to 140x compared to Elasticsearch. |
Real-Time Insights | Basic visibility, no advanced features. | Dashboards, real-time queries, and alerts (e.g., for status:500) keep you proactive. |
Scalability | Struggles with high log volumes. | Scales seamlessly from small projects to production apps. |
For Heroku users, OpenObserve transforms log monitoring into a powerful tool. You can pinpoint dyno failures, trace API timeouts, and analyze historical trends—all in one place. Let’s dive into setting this up.
Before we begin, ensure you have the following:
With these tools in place, you’re ready to start.
Check the version:
heroku --version
You should see something like heroku/10.3.0 darwin-arm64 node-v23.10.0 (version may vary).
Authenticate your CLI:
heroku login
Press any key to open the browser, log in, and confirm: Logged in as your-email@domain.com.
Let’s create a Node.js app using Express to generate diverse logs—app logs, router logs, error logs, and system logs. We’ll deploy it via the Heroku dashboard for a user-friendly experience.
In your terminal, create a new directory and initialize a Node.js project:
mkdir heroku-log-demo
cd heroku-log-demo
npm init -y
Install Express as a dependency:
npm install express
Create index.js to generate various log types:
const express = require('express');
const app = express();
// Middleware to simulate router logs
app.use((req, res, next) => {
console.log(`[Router] ${req.method} request to ${req.path} from ${req.ip} at ${new Date().toISOString()}`);
next();
});
// Simulate app activity logs
app.get('/', (req, res) => {
const timestamp = new Date().toISOString();
console.log(`[App] GET / at ${timestamp}`);
res.send('Heroku Log Demo');
});
// Simulate error logs
app.get('/error', (req, res) => {
const timestamp = new Date().toISOString();
console.error(`[Error] /error failure at ${timestamp}`);
res.status(500).send('Server Error');
});
// Simulate health check logs
app.get('/health', (req, res) => {
const timestamp = new Date().toISOString();
console.log(`[Health] Health check passed at ${timestamp}`);
res.send('OK');
});
// Simulate periodic system logs
setInterval(() => {
console.log(`[System] Dyno heartbeat at ${new Date().toISOString()}`);
}, 60000); // Log every 60 seconds
const port = process.env.PORT || 3000;
app.listen(port, () => console.log(`[System] Server started on port ${port} at ${new Date().toISOString()}`));
Create a Procfile to tell Heroku how to run the app:
web: node index.js
Update package.json with a start script:
{
"name": "heroku-log-demo",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"express": "^4.21.2"
}
}
Set up Git for deployment:
git init
git add .
git commit -m "App with diverse logs"
heroku git:remote -a heroku-log-demo
git push heroku main
curl https://heroku-log-demo-d1dbe34d60a5.herokuapp.com/
curl https://heroku-log-demo-d1dbe34d60a5.herokuapp.com/error
curl https://heroku-log-demo-d1dbe34d60a5.herokuapp.com/health
2025-03-17T11:11:49.810119+00:00 app[web.1]: [System] Dyno heartbeat at 2025-03-17T11:11:49.810Z
2025-03-17T11:13:13.969414+00:00 app[web.1]: [Router] GET request to / from ::ffff:10.1.26.118 at 2025-03-17T11:13:13.969Z
2025-03-17T11:13:13.975482+00:00 app[web.1]: [App] GET / at 2025-03-17T11:13:13.975Z
2025-03-17T11:13:14.008214+00:00 heroku[router]: at=info method=GET path="/" host=heroku-log-demo-d1dbe34d60a5.herokuapp.com request_id=4c02255d-0fa2-4ea1-aeca-7fd31aebe5a3 fwd="174.29.108.209" dyno=web.1 connect=1ms service=95ms status=304 bytes=149 protocol=https
2025-03-17T11:13:14.140920+00:00 app[web.1]: [Router] GET request to /error from ::ffff:10.1.26.118 at 2025-03-17T11:13:14.140Z
2025-03-17T11:13:14.143019+00:00 app[web.1]: [Error] /error failure at 2025-03-17T11:13:14.143Z
2025-03-17T11:13:14.143019+00:00 heroku[router]: at=info method=GET path="/error" host=heroku-log-demo-d1dbe34d60a5.herokuapp.com request_id=7dff073a-f691-4a33-91ef-f6b15c9ff73d fwd="174.29.108.209" dyno=web.1 connect=0ms service=2ms status=500 bytes=230 protocol=https
2025-03-17T11:13:14.145920+00:00 app[web.1]: [Router] GET request to /health from ::ffff:10.1.26.118 at 2025-03-17T11:13:14.145Z
2025-03-17T11:13:14.146482+00:00 app[web.1]: [Health] Health check passed at 2025-03-17T11:13:14.146Z
2025-03-17T11:13:14.147019+00:00 heroku[router]: at=info method=GET path="/health" host=heroku-log-demo-d1dbe34d60a5.herokuapp.com request_id=<id> fwd="174.29.108.209" dyno=web.1 connect=0ms service=1ms status=200 bytes=200 protocol=https
Before we can forward logs to OpenObserve, we need to grab the endpoint, credentials, and organization details from your OpenObserve Cloud account. These will allow our forwarding app to authenticate and send logs to the correct stream. Log into OpenObserve and follow these steps:
You’ll see a configuration snippet similar to this:
destination d_openobserve_http {
openobserve-log(
url("https://api.openobserve.ai")
organization("your_organization_id")
stream("syslog-ng")
user("your-username@example.com")
password("your_password")
);
};
log {
source(s_src);
destination(d_openobserve_http);
flags(flow-control);
};
We’ll use these details to configure our forwarding app, but we need to adapt them for Heroku logs:
https://api.openobserve.ai/api/your_organization_id/heroku_logs/_json
Keep these details handy—you’ll need them when setting up the forwarding app in the next step. Replace your_organization_id, your-username@example.com, and your_password with the values from your OpenObserve account.
Heroku’s Logplex sends logs as raw text with a syslog prefix (e.g., 140 <190>1 ...). To make these logs searchable in OpenObserve, we need to parse them into structured JSON. We’ll create a separate forwarding app to handle this parsing and forward the logs to OpenObserve Cloud.
Create a separate directory for the forwarding app to avoid conflicts:
cd ..
mkdir heroku-log-forwarder
cd heroku-log-forwarder
npm init -y
Install the required packages:
npm install express body-parser node-fetch@2
Create index.js to parse Heroku logs and forward them to OpenObserve:
const express = require('express');
const bodyParser = require('body-parser');
const fetch = require('node-fetch');
const app = express();
app.use(bodyParser.text({ type: '*/*' })); // Accept raw text logs
const OPENOBSERVE_URL = 'https://api.openobserve.ai/api/<your_organization_id>/heroku_logs/_json';
const OPENOBSERVE_USER = 'your-username@example.com';
const OPENOBSERVE_PASS = 'your_password';
// Regular expression to parse Heroku log format with syslog prefix
const logRegex = /^(\d+) <\d+>1\s+(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}\+\d{2}:\d{2})\s+host\s+(\w+)\s+(\w+)\.(\d+)\s*-\s*(.*)$/;
const routerRegex = /^(\d+) <\d+>1\s+(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}\+\d{2}:\d{2})\s+host\s+heroku\s+(\w+)\s*-\s+at=info\s+(method=\S+\s+path=\S+\s+host=\S+\s+request_id=\S+\s+fwd=\S+\s+dyno=\S+\s+connect=\S+\s+service=\S+\s+status=\S+\s+bytes=\S+\s+protocol=\S+)/;
app.post('/logs', async (req, res) => {
const rawLogs = req.body.split('\n').filter(log => log.trim() !== ''); // Split into lines and filter empty
const enrichedLogs = rawLogs.map(log => {
let match = log.match(logRegex);
if (match) {
const [, _, timestamp, source, dynoNum, dynoId, message] = match;
const dyno = `${dynoNum}.${dynoId}`; // Simplify dyno format
return {
app: "heroku-log-demo",
dyno,
message,
source: source.toLowerCase(),
timestamp
};
}
// Handle router logs
match = log.match(routerRegex);
if (match) {
const [, _, timestamp, source, details] = match;
const params = details.split(' ').reduce((acc, pair) => {
const [key, value] = pair.split('=');
acc[key] = value.replace(/^"(.*)"$/, '$1'); // Remove quotes
return acc;
}, {});
return {
app: "heroku-log-demo",
dyno: params.dyno,
message: details,
source: source.toLowerCase(),
timestamp,
...params
};
}
console.warn(`Unparsed log: ${log}`); // Debug unparsed logs
return { message: log, timestamp: new Date().toISOString() }; // Fallback for unparsed logs
});
try {
const response = await fetch(OPENOBSERVE_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Basic ${Buffer.from(`${OPENOBSERVE_USER}:${OPENOBSERVE_PASS}`).toString('base64')}`
},
body: JSON.stringify(enrichedLogs)
});
if (response.ok) {
console.log(`Forwarded ${enrichedLogs.length} logs to OpenObserve`);
res.sendStatus(200);
} else {
throw new Error(`OpenObserve ingestion failed with status ${response.status}`);
}
} catch (error) {
console.error('Error forwarding logs:', error.message);
res.status(500).send('Forwarding failed');
}
});
const port = process.env.PORT || 3000;
app.listen(port, () => console.log(`Log forwarder running on port ${port}`));
Create a Procfile:
web: node index.js
Update package.json:
{
"name": "heroku-log-forwarder",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"express": "^4.21.2",
"body-parser": "^1.20.3",
"node-fetch": "^2.6.7"
}
}
git init
git add .
git commit -m "Initial forwarder setup with log parsing"
heroku create heroku-log-forwarder
git push heroku main
Remove any existing drains:
heroku drains -a heroku-log-demo
heroku drains:remove <drain-id> -a heroku-log-demo
Add the HTTPS drain to the forwarding app:
heroku drains:add "https://heroku-log-forwarder.herokuapp.com/logs" -a heroku-log-demo
Verify the drain:
heroku drains -a heroku-log-demo
Expect: https://heroku-log-forwarder.herokuapp.com/logs (d.<new-id>).
If you see buffer overflow errors (Error L10), reduce the sampling rate:
heroku drains:update --sampling-rate 25 d.<id-from-drains> -a heroku-log-demo
Generate logs by hitting your app’s endpoints:
for i in {1..20}; do curl https://heroku-log-demo-d1dbe34d60a5.herokuapp.com/; curl https://heroku-log-demo-d1dbe34d60a5.herokuapp.com/error; curl https://heroku-log-demo-d1dbe34d60a5.herokuapp.com/health; sleep 2; done
Check the forwarder logs to confirm forwarding:
heroku logs --tail -a heroku-log-forwarder
Check OpenObserve:
{
"_timestamp": 1742212529552670,
"app": "heroku-log-demo",
"dyno": "web.1",
"message": "[Health] Health check passed at 2025-03-17T11:55:29.180Z",
"source": "app",
"timestamp": "2025-03-17T11:55:29.181454+00:00"
},
{
"_timestamp": 1742212529463033,
"app": "heroku-log-demo",
"bytes": "200",
"connect": "1ms",
"dyno": "web.1",
"fwd": "174.29.108.209",
"host": "heroku-log-demo-d1dbe34d60a5.herokuapp.com",
"message": "method=GET path=\"/health\" host=heroku-log-demo-d1dbe34d60a5.herokuapp.com request_id=5e94fa15-2ec6-4ac7-a3a3-b34652c9459a fwd=\"174.29.108.209\" dyno=web.1 connect=1ms service=1ms status=200 bytes=200 protocol=https",
"method": "GET",
"path": "/health",
"protocol": "https",
"request_id": "5e94fa15-2ec6-4ac7-a3a3-b34652c9459a",
"service": "1ms",
"source": "router",
"status": "200",
"timestamp": "2025-03-17T11:55:29.181643+00:00"
}
heroku drains -a heroku-log-demo
heroku drains:add "https://heroku-log-forwarder.herokuapp.com/logs" -a heroku-log-demo
heroku logs --tail -a heroku-log-demo
heroku drains:update --sampling-rate 10 d.<id-from-drains> -a heroku-log-demo
heroku logs --tail -a heroku-log-forwarder
git commit -am "Simplify logs" && git push heroku main
curl https://heroku-log-forwarder.herokuapp.com/logs
Contact OpenObserve support if the issue persists.
You’ve successfully built a robust log monitoring system for your Heroku app with OpenObserve Cloud. Now, you can debug issues, trace performance bottlenecks, and gain historical insights—all in a searchable, structured format. With your Heroku logs correctly streaming into OpenObserve, you can further process them using pipelines, visualize them using interactive dashboards, or set up custom alerts to proactively assess and mitigate potential issues with your application.
To make your monitoring pipeline even more effective, consider these additional steps:
Ready to explore more? Join the OpenObserve Slack community for expert support, practical tips, and updates to keep your logging setup running at its best.