Export Google Analytics Data to PDF Reports via API
Google Analytics 4 dashboards are great for real-time monitoring, but stakeholders often need a formatted PDF they can read offline, store in a shared drive, or attach to a weekly email digest. The GA4 UI has no built-in PDF export, and copying data into slide decks manually is not scalable.
This guide shows you how to automate the full pipeline: fetch metrics from the GA4 Data API, render them with Chart.js, and generate a branded PDF using the FUNBREW PDF API — in Node.js and Python.
Architecture Overview
GA4 Data API → Node.js/Python → HTML template → FUNBREW PDF API → Email / S3
- Fetch GA4 metrics via the
googleapis(Node.js) orgoogle-analytics-data(Python) client - Render metrics and charts into an HTML template
- Convert HTML to PDF using the FUNBREW PDF REST API
- Deliver by email or upload to cloud storage
This pattern is covered in more detail in the PDF report generation guide and in the automation use cases.
Step 1 — Set Up GA4 Data API Access
Create a service account in Google Cloud Console, grant it Viewer access to your GA4 property, and download the JSON credentials file.
npm install googleapis @google-analytics/data nodemailer
Step 2 — Fetch GA4 Metrics (Node.js)
const { BetaAnalyticsDataClient } = require('@google-analytics/data');
const path = require('path');
// Point to your service account credentials
const analyticsClient = new BetaAnalyticsDataClient({
keyFilename: path.join(__dirname, 'ga4-credentials.json'),
});
const GA4_PROPERTY_ID = 'properties/XXXXXXXX'; // replace with your property ID
async function fetchMonthlyMetrics(startDate, endDate) {
const [response] = await analyticsClient.runReport({
property: GA4_PROPERTY_ID,
dateRanges: [{ startDate, endDate }],
dimensions: [{ name: 'date' }],
metrics: [
{ name: 'sessions' },
{ name: 'activeUsers' },
{ name: 'screenPageViews' },
{ name: 'bounceRate' },
],
orderBys: [{ dimension: { dimensionName: 'date' } }],
});
return response.rows.map((row) => ({
date: row.dimensionValues[0].value,
sessions: parseInt(row.metricValues[0].value, 10),
users: parseInt(row.metricValues[1].value, 10),
pageviews: parseInt(row.metricValues[2].value, 10),
bounceRate: parseFloat(row.metricValues[3].value),
}));
}
Step 3 — Build the HTML Report Template
The template combines a KPI summary table with a Chart.js line chart. Because FUNBREW PDF renders with headless Chromium, Chart.js works out of the box.
function buildReportHTML(metrics, reportPeriod) {
const labels = metrics.map((m) => m.date);
const sessionData = metrics.map((m) => m.sessions);
const userDataArr = metrics.map((m) => m.users);
const totals = metrics.reduce(
(acc, m) => {
acc.sessions += m.sessions;
acc.users += m.users;
acc.pageviews += m.pageviews;
return acc;
},
{ sessions: 0, users: 0, pageviews: 0 }
);
const avgBounce = (
metrics.reduce((s, m) => s + m.bounceRate, 0) / metrics.length
).toFixed(1);
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, 'Segoe UI', sans-serif; font-size: 13px;
color: #1e293b; background: #fff; padding: 32px; }
h1 { font-size: 22px; font-weight: 700; color: #0f172a; margin-bottom: 4px; }
.period { font-size: 13px; color: #64748b; margin-bottom: 24px; }
.kpi-grid { display: flex; gap: 16px; margin-bottom: 28px; }
.kpi { flex: 1; background: #f8fafc; border: 1px solid #e2e8f0;
border-radius: 8px; padding: 16px; }
.kpi-label { font-size: 11px; color: #64748b; text-transform: uppercase;
letter-spacing: .5px; margin-bottom: 6px; }
.kpi-value { font-size: 26px; font-weight: 700; color: #0f172a; }
.chart-wrap { margin-bottom: 24px; }
h2 { font-size: 15px; font-weight: 600; margin-bottom: 12px; }
canvas { max-height: 220px; }
@page { size: A4; margin: 15mm; }
@media print { body { padding: 0; } }
</style>
</head>
<body>
<h1>Monthly Analytics Report</h1>
<div class="period">${reportPeriod}</div>
<div class="kpi-grid">
<div class="kpi"><div class="kpi-label">Sessions</div>
<div class="kpi-value">${totals.sessions.toLocaleString()}</div></div>
<div class="kpi"><div class="kpi-label">Users</div>
<div class="kpi-value">${totals.users.toLocaleString()}</div></div>
<div class="kpi"><div class="kpi-label">Pageviews</div>
<div class="kpi-value">${totals.pageviews.toLocaleString()}</div></div>
<div class="kpi"><div class="kpi-label">Avg Bounce Rate</div>
<div class="kpi-value">${avgBounce}%</div></div>
</div>
<div class="chart-wrap">
<h2>Sessions & Users — Daily Trend</h2>
<canvas id="trendChart"></canvas>
</div>
<script>
new Chart(document.getElementById('trendChart'), {
type: 'line',
data: {
labels: ${JSON.stringify(labels)},
datasets: [
{ label: 'Sessions', data: ${JSON.stringify(sessionData)},
borderColor: '#6366f1', backgroundColor: 'rgba(99,102,241,.1)',
tension: 0.3, fill: true },
{ label: 'Users', data: ${JSON.stringify(userDataArr)},
borderColor: '#10b981', backgroundColor: 'rgba(16,185,129,.1)',
tension: 0.3, fill: true },
],
},
options: {
animation: false,
plugins: { legend: { position: 'bottom' } },
scales: { y: { beginAtZero: true } },
},
});
</script>
</body>
</html>`;
}
Step 4 — Convert HTML to PDF with FUNBREW PDF
const https = require('https');
async function htmlToPdf(html) {
const payload = JSON.stringify({
html,
options: {
format: 'A4',
printBackground: true,
displayHeaderFooter: true,
footerTemplate:
'<div style="font-size:9pt;color:#64748b;width:100%;text-align:center;">' +
'Page <span class="pageNumber"></span> of <span class="totalPages"></span>' +
'</div>',
marginTop: '15mm',
marginBottom: '20mm',
},
});
return new Promise((resolve, reject) => {
const req = https.request(
{
hostname: 'pdf.funbrew.cloud',
path: '/api/v1/pdf',
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.FUNBREW_PDF_API_KEY}`,
},
},
(res) => {
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => {
if (res.statusCode === 200) resolve(Buffer.concat(chunks));
else reject(new Error(`PDF API error ${res.statusCode}`));
});
}
);
req.on('error', reject);
req.write(payload);
req.end();
});
}
See the FUNBREW PDF documentation for a full list of options, including watermarks, custom headers, and password protection.
Step 5 — Wire It Together and Email the PDF
const nodemailer = require('nodemailer');
const fs = require('fs');
async function generateAndSendReport() {
// Last calendar month
const now = new Date();
const firstOfLastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const lastOfLastMonth = new Date(now.getFullYear(), now.getMonth(), 0);
const startDate = firstOfLastMonth.toISOString().slice(0, 10);
const endDate = lastOfLastMonth.toISOString().slice(0, 10);
const period = `${startDate} – ${endDate}`;
console.log(`Fetching GA4 data: ${period}`);
const metrics = await fetchMonthlyMetrics(startDate, endDate);
console.log('Rendering HTML template…');
const html = buildReportHTML(metrics, period);
console.log('Generating PDF…');
const pdfBuffer = await htmlToPdf(html);
// Save locally
const filename = `analytics-report-${startDate.slice(0, 7)}.pdf`;
fs.writeFileSync(filename, pdfBuffer);
// Email
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: 587,
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
});
await transporter.sendMail({
from: process.env.SMTP_USER,
to: process.env.REPORT_RECIPIENTS, // comma-separated
subject: `Analytics Report — ${period}`,
text: `Please find the monthly analytics report attached.`,
attachments: [{ filename, content: pdfBuffer }],
});
console.log(`Report sent: ${filename}`);
}
generateAndSendReport().catch(console.error);
Python Version
import os
import json
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.base import MIMEBase
from email.mime.text import MIMEText
from email import encoders
from datetime import date, timedelta
from calendar import monthrange
import requests
from google.analytics.data_v1beta import BetaAnalyticsDataClient
from google.analytics.data_v1beta.types import (
DateRange, Dimension, Metric, RunReportRequest, OrderBy
)
GA4_PROPERTY_ID = "properties/XXXXXXXX"
def fetch_monthly_metrics(start_date: str, end_date: str):
client = BetaAnalyticsDataClient.from_service_account_file("ga4-credentials.json")
request = RunReportRequest(
property=GA4_PROPERTY_ID,
date_ranges=[DateRange(start_date=start_date, end_date=end_date)],
dimensions=[Dimension(name="date")],
metrics=[
Metric(name="sessions"),
Metric(name="activeUsers"),
Metric(name="screenPageViews"),
Metric(name="bounceRate"),
],
order_bys=[OrderBy(dimension={"dimension_name": "date"})],
)
response = client.run_report(request)
return [
{
"date": row.dimension_values[0].value,
"sessions": int(row.metric_values[0].value),
"users": int(row.metric_values[1].value),
"pageviews": int(row.metric_values[2].value),
"bounce_rate": float(row.metric_values[3].value),
}
for row in response.rows
]
def html_to_pdf(html: str) -> bytes:
resp = requests.post(
"https://pdf.funbrew.cloud/api/v1/pdf",
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {os.environ['FUNBREW_PDF_API_KEY']}",
},
json={
"html": html,
"options": {
"format": "A4",
"printBackground": True,
"displayHeaderFooter": True,
"footerTemplate": (
'<div style="font-size:9pt;color:#64748b;width:100%;text-align:center;">'
'Page <span class="pageNumber"></span> of '
'<span class="totalPages"></span></div>'
),
"marginTop": "15mm",
"marginBottom": "20mm",
},
},
timeout=30,
)
resp.raise_for_status()
return resp.content
Schedule the Report (Cron)
Add this to your server's crontab (crontab -e) to run on the 1st of every month at 8 AM:
0 8 1 * * /usr/bin/node /app/generate-ga4-report.js >> /var/log/reports.log 2>&1
For cloud environments, use AWS EventBridge Scheduler or Google Cloud Scheduler to trigger an HTTP endpoint that runs the same logic. For multi-client agencies generating one report per client, see the bulk PDF generation guide.
Common Issues
| Problem | Cause | Fix |
|---|---|---|
| Chart missing in PDF | Chart.js animation not disabled | Add animation: false to Chart.js config |
403 Forbidden from GA4 API |
Service account not added to property | Add service account email as Viewer in GA4 Admin → Property Access |
| PDF is blank | HTML rendering error (JS exception) | Check browser console in the playground by pasting the HTML |
| Cron not firing | Wrong timezone in crontab | Add TZ=UTC before the cron expression or use crontab with explicit timezone |
For more rendering issues, see the HTML to PDF troubleshooting guide.
Next Steps
- Try the template in the FUNBREW PDF playground
- Add per-client branding (logo, colors) by parameterizing the HTML template
- Extend with top-page or channel breakdowns using additional GA4
dimensions - Explore batch PDF generation to generate 50+ client reports in parallel