Use Cases

Automated Report Generation with AI

Build automated report generation with data analysis, LLM narratives, multi-format output, and scheduled delivery in Node.js.

Automated Report Generation with AI

Automated report generation is one of the highest-ROI applications of LLMs in any engineering organization. Instead of spending hours every week pulling data, writing summaries, and formatting slides, you build a pipeline that collects metrics, feeds them to an LLM for narrative analysis, and delivers polished reports on a schedule. I have shipped this pattern at multiple companies, and it consistently saves teams 10-20 hours per week while producing more consistent, data-driven reports than humans typically write under time pressure.

Prerequisites

  • Node.js v18 or later
  • PostgreSQL database with application metrics
  • An Anthropic API key (Claude) or OpenAI API key
  • Basic familiarity with SQL queries and Express.js
  • npm packages: pg, @anthropic-ai/sdk, node-cron, nodemailer, puppeteer, handlebars

The Value of Automated AI Reports

Manual reports suffer from three chronic problems: they are inconsistent in structure and depth, they arrive late because someone has to carve out time to write them, and they omit insights because humans get fatigued scanning dashboards.

AI-generated reports solve all three. The structure is templated, so every report covers the same sections. The pipeline runs on a cron schedule, so delivery is predictable. And LLMs are tireless pattern detectors — give them a table of metrics with week-over-week deltas and they will flag anomalies you would have glossed over at 5 PM on a Friday.

The key insight is that LLMs are not replacing your analytics. They are translating structured data into narrative that humans actually read. A table of numbers gets skimmed. A paragraph that says "API latency increased 34% this week, driven primarily by the /checkout endpoint which saw a 2x increase in P99 response time" gets acted on.

Designing a Report Generation Pipeline

Every automated report system follows the same five-stage pipeline:

  1. Data Collection — Query databases, APIs, log aggregators, and monitoring tools to gather raw metrics
  2. Analysis — Compute deltas, trends, percentiles, and anomalies from the raw data
  3. Narrative Generation — Feed structured analysis to an LLM to produce human-readable summaries
  4. Formatting — Render narratives and data into HTML, PDF, Markdown, or email templates
  5. Delivery — Send the formatted report via email, Slack, or archive it to a file store

Each stage should be a discrete function that can be tested and run independently. This is not a monolith — it is a pipeline with clear inputs and outputs at each step.

[PostgreSQL] → [Data Collector] → [Analyzer] → [LLM Narrator] → [Formatter] → [Delivery]
     ↓               ↓                ↓              ↓               ↓            ↓
   Raw data      Structured       Computed       Narrative        HTML/PDF      Email/
                  metrics         analysis        text            output        Slack

Querying Data Sources for Report Content

Start with a data collection module that pulls from your sources. In most organizations, the primary source is PostgreSQL, but you will also hit REST APIs, log stores, and monitoring endpoints.

var pg = require("pg");

var pool = new pg.Pool({
  connectionString: process.env.DATABASE_URL
});

function collectMetrics(startDate, endDate) {
  var queries = {
    apiRequests: `
      SELECT
        date_trunc('day', created_at) as day,
        COUNT(*) as total_requests,
        AVG(response_time_ms) as avg_response_time,
        PERCENTILE_CONT(0.99) WITHIN GROUP (ORDER BY response_time_ms) as p99_response_time,
        COUNT(CASE WHEN status_code >= 500 THEN 1 END) as error_count
      FROM api_logs
      WHERE created_at BETWEEN $1 AND $2
      GROUP BY date_trunc('day', created_at)
      ORDER BY day
    `,
    userActivity: `
      SELECT
        COUNT(DISTINCT user_id) as active_users,
        COUNT(*) as total_sessions,
        AVG(session_duration_seconds) as avg_session_duration
      FROM user_sessions
      WHERE started_at BETWEEN $1 AND $2
    `,
    revenue: `
      SELECT
        SUM(amount_cents) / 100.0 as total_revenue,
        COUNT(*) as transaction_count,
        AVG(amount_cents) / 100.0 as avg_transaction
      FROM transactions
      WHERE completed_at BETWEEN $1 AND $2
        AND status = 'completed'
    `,
    topEndpoints: `
      SELECT
        endpoint,
        COUNT(*) as hit_count,
        AVG(response_time_ms) as avg_latency,
        COUNT(CASE WHEN status_code >= 500 THEN 1 END) as errors
      FROM api_logs
      WHERE created_at BETWEEN $1 AND $2
      GROUP BY endpoint
      ORDER BY hit_count DESC
      LIMIT 10
    `
  };

  var results = {};
  var keys = Object.keys(queries);

  return Promise.all(
    keys.map(function (key) {
      return pool.query(queries[key], [startDate, endDate]).then(function (res) {
        results[key] = res.rows;
      });
    })
  ).then(function () {
    return results;
  });
}

module.exports = { collectMetrics: collectMetrics };

For external APIs, wrap each source in its own function with error handling and timeouts. A report should never fail because one data source is temporarily down — log the failure and note the missing data in the report itself.

var https = require("https");

function fetchUptimeMetrics(serviceId) {
  return new Promise(function (resolve, reject) {
    var timeout = setTimeout(function () {
      reject(new Error("Uptime API timeout after 10s"));
    }, 10000);

    var options = {
      hostname: "api.uptimerobot.com",
      path: "/v2/getMonitors",
      method: "POST",
      headers: { "Content-Type": "application/json" }
    };

    var req = https.request(options, function (res) {
      var body = "";
      res.on("data", function (chunk) { body += chunk; });
      res.on("end", function () {
        clearTimeout(timeout);
        try {
          resolve(JSON.parse(body));
        } catch (e) {
          reject(new Error("Failed to parse uptime response"));
        }
      });
    });

    req.on("error", function (err) {
      clearTimeout(timeout);
      resolve({ error: err.message, monitors: [] });
    });

    req.write(JSON.stringify({ api_key: process.env.UPTIME_API_KEY, monitors: serviceId }));
    req.end();
  });
}

Implementing Data Summarization with LLMs

Once you have raw data, compute the analysis layer before sending anything to the LLM. Do your math in JavaScript or SQL — do not ask the LLM to calculate percentages or deltas. LLMs are language models, not calculators.

function analyzeMetrics(currentPeriod, previousPeriod) {
  var current = currentPeriod.apiRequests;
  var previous = previousPeriod.apiRequests;

  var totalRequests = current.reduce(function (sum, day) {
    return sum + parseInt(day.total_requests);
  }, 0);

  var prevTotalRequests = previous.reduce(function (sum, day) {
    return sum + parseInt(day.total_requests);
  }, 0);

  var requestsDelta = prevTotalRequests > 0
    ? ((totalRequests - prevTotalRequests) / prevTotalRequests * 100).toFixed(1)
    : "N/A";

  var avgLatency = current.reduce(function (sum, day) {
    return sum + parseFloat(day.avg_response_time);
  }, 0) / current.length;

  var prevAvgLatency = previous.reduce(function (sum, day) {
    return sum + parseFloat(day.avg_response_time);
  }, 0) / previous.length;

  var latencyDelta = ((avgLatency - prevAvgLatency) / prevAvgLatency * 100).toFixed(1);

  var errorRate = current.reduce(function (sum, day) {
    return sum + parseInt(day.error_count);
  }, 0) / totalRequests * 100;

  return {
    totalRequests: totalRequests,
    requestsDelta: requestsDelta,
    avgLatency: avgLatency.toFixed(1),
    latencyDelta: latencyDelta,
    errorRate: errorRate.toFixed(2),
    activeUsers: parseInt(currentPeriod.userActivity[0].active_users),
    revenue: parseFloat(currentPeriod.revenue[0].total_revenue),
    topEndpoints: currentPeriod.topEndpoints
  };
}

Generating Narrative Sections from Structured Data

Now feed the pre-computed analysis to an LLM. The prompt is everything here. Be specific about what you want, provide the exact numbers, and tell the model what format to use.

var Anthropic = require("@anthropic-ai/sdk");

var client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });

function generateNarrative(analysis, reportType) {
  var systemPrompt = [
    "You are a senior engineering analyst writing a weekly performance report.",
    "Write in a direct, professional tone. Lead with the most important findings.",
    "Always reference specific numbers. Flag anything that changed more than 20% as noteworthy.",
    "Do not use marketing language or filler. Be concise and actionable.",
    "Format output as JSON with keys: executive_summary, performance_analysis,",
    "risk_factors, recommendations. Each value is a string of markdown text."
  ].join(" ");

  var userPrompt = [
    "Generate the weekly report narrative from these metrics:\n\n",
    "Total API Requests: " + analysis.totalRequests.toLocaleString(),
    " (WoW change: " + analysis.requestsDelta + "%)\n",
    "Average Latency: " + analysis.avgLatency + "ms",
    " (WoW change: " + analysis.latencyDelta + "%)\n",
    "Error Rate: " + analysis.errorRate + "%\n",
    "Active Users: " + analysis.activeUsers.toLocaleString() + "\n",
    "Revenue: $" + analysis.revenue.toLocaleString() + "\n\n",
    "Top Endpoints by Traffic:\n",
    analysis.topEndpoints.map(function (ep) {
      return "- " + ep.endpoint + ": " + ep.hit_count + " hits, " +
        parseFloat(ep.avg_latency).toFixed(1) + "ms avg, " +
        ep.errors + " errors";
    }).join("\n"),
    "\n\nReport type: " + reportType
  ].join("");

  return client.messages.create({
    model: "claude-sonnet-4-20250514",
    max_tokens: 2000,
    system: systemPrompt,
    messages: [{ role: "user", content: userPrompt }]
  }).then(function (response) {
    var text = response.content[0].text;
    return JSON.parse(text);
  });
}

The critical design choice here is requesting JSON output with specific keys. This gives you structured sections that map directly to your report template. Do not ask for free-form prose and then try to parse it — that path leads to fragile regex parsing and broken reports at 3 AM.

Building Report Templates with Dynamic Sections

Use Handlebars for HTML report templates. It keeps the presentation layer separate from data and narrative generation.

var Handlebars = require("handlebars");
var fs = require("fs");
var path = require("path");

var templateSource = fs.readFileSync(
  path.join(__dirname, "templates", "weekly-report.hbs"),
  "utf8"
);

var template = Handlebars.compile(templateSource);

Handlebars.registerHelper("formatNumber", function (num) {
  return Number(num).toLocaleString();
});

Handlebars.registerHelper("deltaClass", function (delta) {
  var val = parseFloat(delta);
  if (val > 10) return "positive";
  if (val < -10) return "negative";
  return "neutral";
});

function renderReport(analysis, narrative, reportDate) {
  var html = template({
    reportDate: reportDate,
    analysis: analysis,
    narrative: narrative,
    generatedAt: new Date().toISOString()
  });

  return html;
}

Here is a simplified Handlebars template:

<!DOCTYPE html>
<html>
<head>
  <style>
    body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; }
    .metric-card { display: inline-block; padding: 16px; margin: 8px; border: 1px solid #e0e0e0; border-radius: 8px; min-width: 180px; }
    .metric-value { font-size: 28px; font-weight: bold; }
    .positive { color: #2e7d32; }
    .negative { color: #c62828; }
    .neutral { color: #555; }
    .section { margin: 24px 0; }
    h1 { border-bottom: 2px solid #1a73e8; padding-bottom: 8px; }
  </style>
</head>
<body>
  <h1>Weekly Performance Report — {{reportDate}}</h1>

  <div class="metrics-grid">
    <div class="metric-card">
      <div class="metric-label">Total Requests</div>
      <div class="metric-value">{{formatNumber analysis.totalRequests}}</div>
      <div class="{{deltaClass analysis.requestsDelta}}">{{analysis.requestsDelta}}% WoW</div>
    </div>
    <div class="metric-card">
      <div class="metric-label">Avg Latency</div>
      <div class="metric-value">{{analysis.avgLatency}}ms</div>
      <div class="{{deltaClass analysis.latencyDelta}}">{{analysis.latencyDelta}}% WoW</div>
    </div>
    <div class="metric-card">
      <div class="metric-label">Error Rate</div>
      <div class="metric-value">{{analysis.errorRate}}%</div>
    </div>
  </div>

  <div class="section">
    <h2>Executive Summary</h2>
    {{{narrative.executive_summary}}}
  </div>

  <div class="section">
    <h2>Performance Analysis</h2>
    {{{narrative.performance_analysis}}}
  </div>

  <div class="section">
    <h2>Risk Factors</h2>
    {{{narrative.risk_factors}}}
  </div>

  <div class="section">
    <h2>Recommendations</h2>
    {{{narrative.recommendations}}}
  </div>

  <footer>
    <p style="color: #999; font-size: 12px;">Generated automatically at {{generatedAt}}</p>
  </footer>
</body>
</html>

Creating Charts and Visualization Descriptions

For emailed HTML reports, you cannot embed interactive charts. You have two options: generate static chart images server-side, or have the LLM produce descriptive text that replaces charts.

For static images, use a charting library that renders to PNG:

var { ChartJSNodeCanvas } = require("chartjs-node-canvas");

var chartRenderer = new ChartJSNodeCanvas({ width: 600, height: 300 });

function generateLatencyChart(dailyMetrics) {
  var config = {
    type: "line",
    data: {
      labels: dailyMetrics.map(function (d) { return d.day.toISOString().split("T")[0]; }),
      datasets: [{
        label: "Avg Latency (ms)",
        data: dailyMetrics.map(function (d) { return parseFloat(d.avg_response_time); }),
        borderColor: "#1a73e8",
        fill: false
      }, {
        label: "P99 Latency (ms)",
        data: dailyMetrics.map(function (d) { return parseFloat(d.p99_response_time); }),
        borderColor: "#e8501a",
        borderDash: [5, 5],
        fill: false
      }]
    },
    options: {
      plugins: { title: { display: true, text: "API Latency Trend" } }
    }
  };

  return chartRenderer.renderToBuffer(config).then(function (buffer) {
    return "data:image/png;base64," + buffer.toString("base64");
  });
}

Embed the base64 image directly in the HTML. This works in email clients that support inline images and avoids hosting chart images externally.

Implementing Scheduled Report Generation with node-cron

Schedule reports using node-cron. Keep the scheduler thin — it should only orchestrate the pipeline, not contain business logic.

var cron = require("node-cron");

function startScheduler() {
  // Weekly report every Monday at 7 AM UTC
  cron.schedule("0 7 * * 1", function () {
    console.log("[Report Scheduler] Starting weekly report generation");
    var now = new Date();
    var endDate = new Date(now);
    endDate.setHours(0, 0, 0, 0);

    var startDate = new Date(endDate);
    startDate.setDate(startDate.getDate() - 7);

    var prevStart = new Date(startDate);
    prevStart.setDate(prevStart.getDate() - 7);

    generateWeeklyReport(startDate, endDate, prevStart, startDate)
      .then(function (result) {
        console.log("[Report Scheduler] Report delivered successfully:", result.reportId);
      })
      .catch(function (err) {
        console.error("[Report Scheduler] Report generation failed:", err.message);
        sendAlertNotification("Weekly report generation failed: " + err.message);
      });
  });

  // Daily summary at 8 AM UTC
  cron.schedule("0 8 * * *", function () {
    console.log("[Report Scheduler] Starting daily summary");
    var now = new Date();
    var endDate = new Date(now);
    endDate.setHours(0, 0, 0, 0);

    var startDate = new Date(endDate);
    startDate.setDate(startDate.getDate() - 1);

    generateDailySummary(startDate, endDate)
      .then(function () {
        console.log("[Report Scheduler] Daily summary delivered");
      })
      .catch(function (err) {
        console.error("[Report Scheduler] Daily summary failed:", err.message);
      });
  });

  console.log("[Report Scheduler] Scheduled: weekly (Mon 7AM), daily (8AM)");
}

Multi-Format Output

Different stakeholders want reports in different formats. Build a formatter layer that takes the same data and produces HTML, PDF, Markdown, or email-ready content.

var puppeteer = require("puppeteer");
var fs = require("fs");
var path = require("path");

function formatAsHtml(analysis, narrative, reportDate) {
  return renderReport(analysis, narrative, reportDate);
}

function formatAsPdf(html, outputPath) {
  var browser;
  return puppeteer.launch({ headless: "new" })
    .then(function (b) {
      browser = b;
      return browser.newPage();
    })
    .then(function (page) {
      return page.setContent(html, { waitUntil: "networkidle0" })
        .then(function () {
          return page.pdf({
            path: outputPath,
            format: "A4",
            margin: { top: "20mm", bottom: "20mm", left: "15mm", right: "15mm" },
            printBackground: true
          });
        });
    })
    .then(function (pdfBuffer) {
      if (browser) browser.close();
      return pdfBuffer;
    })
    .catch(function (err) {
      if (browser) browser.close();
      throw err;
    });
}

function formatAsMarkdown(analysis, narrative) {
  var md = "# Weekly Performance Report\n\n";
  md += "## Key Metrics\n\n";
  md += "| Metric | Value | WoW Change |\n";
  md += "|--------|-------|------------|\n";
  md += "| Total Requests | " + analysis.totalRequests.toLocaleString() + " | " + analysis.requestsDelta + "% |\n";
  md += "| Avg Latency | " + analysis.avgLatency + "ms | " + analysis.latencyDelta + "% |\n";
  md += "| Error Rate | " + analysis.errorRate + "% | — |\n";
  md += "| Active Users | " + analysis.activeUsers.toLocaleString() + " | — |\n\n";
  md += "## Executive Summary\n\n" + narrative.executive_summary + "\n\n";
  md += "## Performance Analysis\n\n" + narrative.performance_analysis + "\n\n";
  md += "## Risk Factors\n\n" + narrative.risk_factors + "\n\n";
  md += "## Recommendations\n\n" + narrative.recommendations + "\n";
  return md;
}

PDF generation with Puppeteer is the most reliable approach I have used. Libraries like pdfkit or html-pdf (which uses PhantomJS under the hood) are either too low-level or deprecated. Puppeteer renders your HTML exactly as a browser would, then prints to PDF. The output looks identical to the HTML version.

Personalizing Reports Per Audience

Executive stakeholders want a one-paragraph summary with revenue impact. Engineering managers want latency breakdowns and error budgets. Individual contributors want endpoint-level detail and deployment correlations.

function generatePersonalizedReport(analysis, audience) {
  var prompts = {
    executive: [
      "Write a 3-4 sentence executive summary focusing on business impact.",
      "Mention revenue, user growth, and any service reliability concerns.",
      "Skip technical details. Use plain business language."
    ].join(" "),
    engineering_manager: [
      "Write a detailed engineering report covering API performance, error rates,",
      "and infrastructure concerns. Include specific endpoint data.",
      "Flag any metrics that breach SLA thresholds (99.9% uptime, <200ms p99).",
      "Recommend specific engineering actions."
    ].join(" "),
    detailed_technical: [
      "Write a comprehensive technical analysis. Include all endpoint-level data.",
      "Correlate performance changes with recent deployments if apparent.",
      "Provide specific query optimization or scaling recommendations.",
      "Include suggested monitoring alerts for flagged issues."
    ].join(" ")
  };

  var systemPrompt = prompts[audience] || prompts.engineering_manager;

  return generateNarrative(analysis, systemPrompt);
}

Ship different reports to different Slack channels or mailing lists. The data collection and analysis stages are shared — only the narrative prompt and delivery target change.

Handling Large Datasets

When your metrics table has millions of rows, you cannot dump everything into an LLM prompt. Use these strategies:

Pre-aggregation in SQL. Do the heavy lifting in the database. The queries in the data collection section already use GROUP BY and aggregate functions. Never pull raw rows and aggregate in JavaScript.

Sampling for anomaly detection. If you need the LLM to spot patterns in high-cardinality data, sample intelligently:

function sampleTopAnomalies(metrics, limit) {
  // Sort by absolute deviation from mean and take top N
  var mean = metrics.reduce(function (sum, m) { return sum + m.value; }, 0) / metrics.length;
  var stdDev = Math.sqrt(
    metrics.reduce(function (sum, m) { return sum + Math.pow(m.value - mean, 2); }, 0) / metrics.length
  );

  return metrics
    .map(function (m) {
      m.zScore = Math.abs((m.value - mean) / stdDev);
      return m;
    })
    .sort(function (a, b) { return b.zScore - a.zScore; })
    .slice(0, limit);
}

Key metric extraction. Compute summary statistics in code and only send the summaries to the LLM. Do not send 10,000 rows. Send: min, max, mean, median, p95, p99, trend direction, and the top 5 outliers.

Report Versioning and Archival

Every generated report should be archived with metadata so you can audit what was sent and when.

function archiveReport(reportId, html, pdf, metadata) {
  var archivePath = path.join(__dirname, "archives", reportId);

  return new Promise(function (resolve, reject) {
    fs.mkdir(archivePath, { recursive: true }, function (err) {
      if (err) return reject(err);

      var tasks = [
        writeFilePromise(path.join(archivePath, "report.html"), html),
        writeFilePromise(path.join(archivePath, "metadata.json"), JSON.stringify(metadata, null, 2))
      ];

      if (pdf) {
        tasks.push(writeFilePromise(path.join(archivePath, "report.pdf"), pdf));
      }

      Promise.all(tasks).then(function () {
        resolve({ reportId: reportId, path: archivePath });
      }).catch(reject);
    });
  });
}

function writeFilePromise(filePath, data) {
  return new Promise(function (resolve, reject) {
    fs.writeFile(filePath, data, function (err) {
      if (err) reject(err);
      else resolve();
    });
  });
}

Use a naming convention like 2026-02-10-weekly-engineering for the report ID. Store the raw analysis data alongside the rendered output so you can regenerate reports with updated templates without re-querying the database.

Quality Checks for Generated Reports

Never ship a report without validation. LLMs can hallucinate numbers, miss sections, or produce malformed JSON.

function validateReport(narrative, analysis) {
  var errors = [];

  // Check all required sections exist
  var requiredSections = ["executive_summary", "performance_analysis", "risk_factors", "recommendations"];
  requiredSections.forEach(function (section) {
    if (!narrative[section] || narrative[section].trim().length < 50) {
      errors.push("Missing or too short section: " + section);
    }
  });

  // Check that key numbers appear in the narrative
  var fullText = Object.values(narrative).join(" ");
  var totalRequestsStr = analysis.totalRequests.toLocaleString();
  if (fullText.indexOf(analysis.errorRate) === -1) {
    errors.push("Error rate not mentioned in narrative");
  }

  // Check for hallucinated numbers — any percentage over 1000% is suspicious
  var percentages = fullText.match(/(\d+\.?\d*)%/g) || [];
  percentages.forEach(function (pct) {
    var val = parseFloat(pct);
    if (val > 1000) {
      errors.push("Suspicious percentage found: " + pct);
    }
  });

  // Check narrative length is reasonable (not truncated, not padded)
  var wordCount = fullText.split(/\s+/).length;
  if (wordCount < 100) {
    errors.push("Narrative too short: " + wordCount + " words");
  }
  if (wordCount > 3000) {
    errors.push("Narrative too long: " + wordCount + " words — may indicate repetition");
  }

  return {
    valid: errors.length === 0,
    errors: errors
  };
}

If validation fails, retry the LLM call once with a more explicit prompt. If it fails twice, send the report with a warning banner and alert the engineering team. A late, imperfect report is better than no report, but a silently wrong report is worse than both.

Delivering Reports via Email and Slack

var nodemailer = require("nodemailer");

var transporter = nodemailer.createTransport({
  host: process.env.SMTP_HOST,
  port: parseInt(process.env.SMTP_PORT) || 587,
  secure: false,
  auth: {
    user: process.env.SMTP_USER,
    pass: process.env.SMTP_PASS
  }
});

function deliverViaEmail(html, pdfBuffer, recipients, subject) {
  var attachments = [];
  if (pdfBuffer) {
    attachments.push({
      filename: "report.pdf",
      content: pdfBuffer,
      contentType: "application/pdf"
    });
  }

  var mailOptions = {
    from: '"Automated Reports" <[email protected]>',
    to: recipients.join(", "),
    subject: subject,
    html: html,
    attachments: attachments
  };

  return transporter.sendMail(mailOptions);
}

function deliverViaSlack(markdown, channel) {
  var https = require("https");

  var payload = JSON.stringify({
    channel: channel,
    text: markdown,
    unfurl_links: false
  });

  return new Promise(function (resolve, reject) {
    var req = https.request({
      hostname: "slack.com",
      path: "/api/chat.postMessage",
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "Authorization": "Bearer " + process.env.SLACK_BOT_TOKEN
      }
    }, function (res) {
      var body = "";
      res.on("data", function (chunk) { body += chunk; });
      res.on("end", function () {
        var result = JSON.parse(body);
        if (result.ok) resolve(result);
        else reject(new Error("Slack API error: " + result.error));
      });
    });

    req.on("error", reject);
    req.write(payload);
    req.end();
  });
}

Complete Working Example

Here is the full pipeline wired together — a Node.js service that pulls metrics from PostgreSQL, generates narrative analysis with Claude, renders as HTML with PDF attachment, and delivers via email every Monday morning.

// report-generator.js
var pg = require("pg");
var Anthropic = require("@anthropic-ai/sdk");
var Handlebars = require("handlebars");
var nodemailer = require("nodemailer");
var puppeteer = require("puppeteer");
var cron = require("node-cron");
var fs = require("fs");
var path = require("path");

// --- Configuration ---
var pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
var anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
var transporter = nodemailer.createTransport({
  host: process.env.SMTP_HOST,
  port: 587,
  auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }
});

// --- Data Collection ---
function collectWeeklyMetrics(startDate, endDate) {
  var queries = {
    daily: pool.query(
      "SELECT date_trunc('day', created_at) as day, COUNT(*) as requests, " +
      "AVG(response_time_ms) as avg_latency, " +
      "COUNT(CASE WHEN status_code >= 500 THEN 1 END) as errors " +
      "FROM api_logs WHERE created_at BETWEEN $1 AND $2 " +
      "GROUP BY date_trunc('day', created_at) ORDER BY day",
      [startDate, endDate]
    ),
    users: pool.query(
      "SELECT COUNT(DISTINCT user_id) as active_users FROM user_sessions " +
      "WHERE started_at BETWEEN $1 AND $2",
      [startDate, endDate]
    ),
    revenue: pool.query(
      "SELECT SUM(amount_cents)/100.0 as total, COUNT(*) as txn_count " +
      "FROM transactions WHERE completed_at BETWEEN $1 AND $2 AND status='completed'",
      [startDate, endDate]
    ),
    topEndpoints: pool.query(
      "SELECT endpoint, COUNT(*) as hits, AVG(response_time_ms) as latency, " +
      "COUNT(CASE WHEN status_code >= 500 THEN 1 END) as errors " +
      "FROM api_logs WHERE created_at BETWEEN $1 AND $2 " +
      "GROUP BY endpoint ORDER BY hits DESC LIMIT 10",
      [startDate, endDate]
    )
  };

  var keys = Object.keys(queries);
  return Promise.all(keys.map(function (k) { return queries[k]; }))
    .then(function (results) {
      var data = {};
      keys.forEach(function (k, i) { data[k] = results[i].rows; });
      return data;
    });
}

// --- Analysis ---
function analyzeData(current, previous) {
  var totalReqs = current.daily.reduce(function (s, d) { return s + parseInt(d.requests); }, 0);
  var prevReqs = previous.daily.reduce(function (s, d) { return s + parseInt(d.requests); }, 0);
  var totalErrors = current.daily.reduce(function (s, d) { return s + parseInt(d.errors); }, 0);
  var avgLat = current.daily.reduce(function (s, d) { return s + parseFloat(d.avg_latency); }, 0) / current.daily.length;

  return {
    totalRequests: totalReqs,
    requestsDelta: ((totalReqs - prevReqs) / prevReqs * 100).toFixed(1),
    avgLatency: avgLat.toFixed(1),
    errorRate: (totalErrors / totalReqs * 100).toFixed(2),
    activeUsers: parseInt(current.users[0].active_users),
    revenue: parseFloat(current.revenue[0].total),
    transactionCount: parseInt(current.revenue[0].txn_count),
    topEndpoints: current.topEndpoints
  };
}

// --- Narrative Generation ---
function generateNarrative(analysis) {
  var prompt = [
    "Generate a weekly engineering report from these metrics.",
    "Total Requests: " + analysis.totalRequests + " (" + analysis.requestsDelta + "% WoW)",
    "Avg Latency: " + analysis.avgLatency + "ms",
    "Error Rate: " + analysis.errorRate + "%",
    "Active Users: " + analysis.activeUsers,
    "Revenue: $" + analysis.revenue.toLocaleString(),
    "Top endpoints: " + analysis.topEndpoints.map(function (e) {
      return e.endpoint + " (" + e.hits + " hits, " + parseFloat(e.latency).toFixed(1) + "ms)";
    }).join("; "),
    "",
    "Return JSON with keys: executive_summary, performance_analysis, risk_factors, recommendations.",
    "Each value is a markdown string. Be specific and data-driven."
  ].join("\n");

  return anthropic.messages.create({
    model: "claude-sonnet-4-20250514",
    max_tokens: 2000,
    messages: [{ role: "user", content: prompt }]
  }).then(function (res) {
    return JSON.parse(res.content[0].text);
  });
}

// --- Validation ---
function validateNarrative(narrative) {
  var required = ["executive_summary", "performance_analysis", "risk_factors", "recommendations"];
  var missing = required.filter(function (key) {
    return !narrative[key] || narrative[key].length < 30;
  });
  if (missing.length > 0) {
    throw new Error("Report validation failed — missing sections: " + missing.join(", "));
  }
  return narrative;
}

// --- Rendering ---
function renderHtml(analysis, narrative, reportDate) {
  var html = [
    "<!DOCTYPE html><html><head><style>",
    "body{font-family:sans-serif;max-width:800px;margin:0 auto;padding:20px}",
    ".card{display:inline-block;padding:16px;margin:8px;border:1px solid #ddd;border-radius:8px;min-width:160px}",
    ".val{font-size:24px;font-weight:bold} .section{margin:24px 0}",
    "</style></head><body>",
    "<h1>Weekly Report — " + reportDate + "</h1>",
    "<div class='card'><div>Requests</div><div class='val'>" + analysis.totalRequests.toLocaleString() + "</div><div>" + analysis.requestsDelta + "% WoW</div></div>",
    "<div class='card'><div>Latency</div><div class='val'>" + analysis.avgLatency + "ms</div></div>",
    "<div class='card'><div>Error Rate</div><div class='val'>" + analysis.errorRate + "%</div></div>",
    "<div class='card'><div>Revenue</div><div class='val'>$" + analysis.revenue.toLocaleString() + "</div></div>",
    "<div class='section'><h2>Executive Summary</h2>" + narrative.executive_summary + "</div>",
    "<div class='section'><h2>Performance Analysis</h2>" + narrative.performance_analysis + "</div>",
    "<div class='section'><h2>Risk Factors</h2>" + narrative.risk_factors + "</div>",
    "<div class='section'><h2>Recommendations</h2>" + narrative.recommendations + "</div>",
    "<footer><p style='color:#999;font-size:12px'>Generated " + new Date().toISOString() + "</p></footer>",
    "</body></html>"
  ].join("\n");
  return html;
}

function renderPdf(html) {
  var browser;
  return puppeteer.launch({ headless: "new" }).then(function (b) {
    browser = b;
    return browser.newPage();
  }).then(function (page) {
    return page.setContent(html, { waitUntil: "networkidle0" }).then(function () {
      return page.pdf({ format: "A4", margin: { top: "20mm", bottom: "20mm", left: "15mm", right: "15mm" } });
    });
  }).then(function (pdf) {
    browser.close();
    return pdf;
  });
}

// --- Delivery ---
function sendReport(html, pdfBuffer, reportDate) {
  var recipients = process.env.REPORT_RECIPIENTS.split(",");
  return transporter.sendMail({
    from: '"Weekly Reports" <[email protected]>',
    to: recipients.join(", "),
    subject: "Weekly Engineering Report — " + reportDate,
    html: html,
    attachments: [{
      filename: "weekly-report-" + reportDate + ".pdf",
      content: pdfBuffer,
      contentType: "application/pdf"
    }]
  });
}

// --- Archive ---
function archiveReport(reportDate, html, metadata) {
  var dir = path.join(__dirname, "archives", reportDate);
  fs.mkdirSync(dir, { recursive: true });
  fs.writeFileSync(path.join(dir, "report.html"), html);
  fs.writeFileSync(path.join(dir, "metadata.json"), JSON.stringify(metadata, null, 2));
  console.log("[Archive] Saved to " + dir);
}

// --- Pipeline Orchestration ---
function runWeeklyReport() {
  var now = new Date();
  var endDate = new Date(now); endDate.setHours(0, 0, 0, 0);
  var startDate = new Date(endDate); startDate.setDate(startDate.getDate() - 7);
  var prevStart = new Date(startDate); prevStart.setDate(prevStart.getDate() - 7);
  var reportDate = startDate.toISOString().split("T")[0];

  console.log("[Report] Generating weekly report for " + reportDate);

  var currentData, previousData, analysis, narrative, html;

  return Promise.all([
    collectWeeklyMetrics(startDate, endDate),
    collectWeeklyMetrics(prevStart, startDate)
  ]).then(function (results) {
    currentData = results[0];
    previousData = results[1];
    analysis = analyzeData(currentData, previousData);
    console.log("[Report] Analysis complete. Generating narrative...");
    return generateNarrative(analysis);
  }).then(function (narr) {
    narrative = validateNarrative(narr);
    console.log("[Report] Narrative generated. Rendering...");
    html = renderHtml(analysis, narrative, reportDate);
    return renderPdf(html);
  }).then(function (pdfBuffer) {
    console.log("[Report] Rendering complete. Delivering...");
    archiveReport(reportDate, html, { analysis: analysis, generatedAt: new Date().toISOString() });
    return sendReport(html, pdfBuffer, reportDate);
  }).then(function () {
    console.log("[Report] Weekly report delivered successfully for " + reportDate);
  });
}

// --- Scheduler ---
cron.schedule("0 7 * * 1", function () {
  runWeeklyReport().catch(function (err) {
    console.error("[Report] FAILED:", err.message);
  });
});

console.log("[Report Generator] Running. Weekly reports scheduled for Monday 7 AM UTC.");

// Allow manual trigger
module.exports = { runWeeklyReport: runWeeklyReport };

To run manually:

npm install pg @anthropic-ai/sdk node-cron nodemailer puppeteer handlebars

# Set environment variables
export DATABASE_URL="postgresql://user:pass@localhost:5432/myapp"
export ANTHROPIC_API_KEY="sk-ant-..."
export SMTP_HOST="smtp.gmail.com"
export SMTP_USER="[email protected]"
export SMTP_PASS="app-password-here"
export REPORT_RECIPIENTS="[email protected],[email protected]"

# Run the generator
node report-generator.js

# Or trigger a report immediately from another script
node -e "require('./report-generator').runWeeklyReport().then(function() { process.exit(0); })"

Common Issues & Troubleshooting

1. LLM returns invalid JSON

SyntaxError: Unexpected token < in JSON at position 0

This happens when the LLM wraps its response in markdown code fences. Strip them before parsing:

function parseJsonResponse(text) {
  var cleaned = text.replace(/^```json?\n?/m, "").replace(/\n?```$/m, "").trim();
  return JSON.parse(cleaned);
}

2. Puppeteer fails on headless server

Error: Failed to launch the browser process!
/app/node_modules/puppeteer/.local-chromium/linux-xxx/chrome-linux/chrome:
error while loading shared libraries: libnss3.so: cannot open shared object file

Install the required system dependencies. On Debian/Ubuntu:

apt-get install -y libx11-xcb1 libxcomposite1 libxdamage1 libxrandr2 libgbm1 \
  libasound2 libpangocairo-1.0-0 libatk1.0-0 libcups2 libnss3 libxss1 libgtk-3-0

Or use puppeteer-core with a pre-installed Chromium to keep your Docker image small.

3. PostgreSQL connection pool exhaustion

Error: too many clients already

This happens when report queries run concurrently and exceed the pool limit. Set explicit pool boundaries:

var pool = new pg.Pool({
  connectionString: process.env.DATABASE_URL,
  max: 5,
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 5000
});

Also make sure you are not creating new Pool instances inside functions. The pool should be a module-level singleton.

4. Email rejected as spam

550 5.7.1 Message rejected: SPF/DKIM verification failed

Configure SPF, DKIM, and DMARC records for your sending domain. Use a dedicated transactional email provider like SendGrid, Postmark, or Amazon SES instead of raw SMTP. They handle authentication and deliverability out of the box.

5. LLM rate limits during batch report generation

Error: 429 Too Many Requests - rate_limit_exceeded

When generating personalized reports for multiple audiences, add retry logic with exponential backoff:

function withRetry(fn, maxRetries, baseDelay) {
  var attempt = 0;
  function tryCall() {
    return fn().catch(function (err) {
      attempt++;
      if (attempt >= maxRetries || err.status !== 429) throw err;
      var delay = baseDelay * Math.pow(2, attempt);
      console.log("[Retry] Attempt " + attempt + ", waiting " + delay + "ms");
      return new Promise(function (resolve) {
        setTimeout(resolve, delay);
      }).then(tryCall);
    });
  }
  return tryCall();
}

Best Practices

  • Compute before you prompt. Do all mathematical analysis in SQL or JavaScript. Send only pre-computed summaries to the LLM. Asking an LLM to calculate percentage changes from raw data is asking for hallucinated numbers.

  • Request structured output. Always ask the LLM to return JSON with specific keys. This gives you predictable sections that map to your template, and it is far easier to validate than free-form prose.

  • Validate every report. Check that all required sections exist, that mentioned numbers match your computed analysis, and that no suspiciously large percentages appear. A report with wrong numbers is worse than no report.

  • Archive everything. Store the raw data, computed analysis, generated narrative, and rendered output for every report. You will need this for auditing, debugging, and regeneration.

  • Degrade gracefully. If one data source is unreachable, generate the report with available data and note the gap. If the LLM fails after retries, send a data-only report without narrative. Never let a single failure mode block the entire pipeline.

  • Use separate prompts for separate audiences. Do not try to generate one report for everyone. The executive summary and the detailed technical analysis require fundamentally different prompts and levels of detail.

  • Set explicit token limits. Cap max_tokens to prevent the LLM from generating novel-length narratives. For weekly reports, 1500-2000 tokens per section is usually sufficient.

  • Version your prompts. Store prompts in files or a database, not inline in code. When you tweak the prompt to improve narrative quality, you want to know which prompt version generated which report.

  • Test with static data. Build a test harness that feeds fixed metric snapshots through the pipeline. This lets you iterate on prompts and templates without hitting the database or LLM API every time.

References

Powered by Contentful