Build a Real-Time System Health Monitor

Build a C++ diagnostic engine with custom data structures and anomaly detection.

Introduction

30 Second Summary

When a server starts behaving strangely (high CPU, memory spikes, slow responses), engineers need tools that catch problems before users notice. The faster you can flag an anomaly, the faster you can fix it.

In this project, you will build a command-line system health diagnostics engine in C++ that processes streams of server metrics, computes rolling statistics, and flags anomalies in real time.

What You'll Build

A C++ diagnostic engine that ingests simulated server metrics through a custom ring buffer, applies sliding window statistics, and detects anomalies using z-score analysis.

By the end of this project, you'll have:

  • A custom ring buffer data structure with O(1) push and circular overwrite, benchmarked at 100K+ operations per second.
  • A statistics engine that computes rolling mean, standard deviation, and z-scores over sliding time windows to detect anomalies.
  • A comprehensive unit test suite using Catch2 that validates data structure correctness and anomaly detection accuracy.
  • Secret Mission: Add multithreaded producer-consumer processing with a thread-safe event queue.

Are there any prerequisites?

No prior C++ experience is required. This guide includes full setup instructions for all tools you need.

You will need a Mac with macOS installed. This project uses Xcode Command Line Tools, which provides the clang++ compiler.

Before We Start

Before We Start...

Set Up Your C++ Development Environment

Before you can write any C++ code, you need a compiler that turns your source files into executable programs.

In this step, you will install Apple's Xcode Command Line Tools (which includes the clang++ compiler), create a structured project directory, and download the Catch2 testing framework.

In this step, get ready to:
  • Install and verify the C++ compiler.
  • Create the project folder structure.
  • Download the Catch2 test framework files.
Install Xcode Command Line Tools

The Xcode Command Line Tools package from Apple includes clang++, the C++ compiler you will use throughout this project.

  • Press Cmd+Space (macOS) to open Spotlight search.
  • Type Terminal and press Enter to open it.
  • Install Xcode Command Line Tools by running this command:
xcode-select --install

What does this command do?

This triggers Apple's installer for developer command-line tools. A dialog will appear asking you to confirm the installation.

Click Install when prompted, then wait for the download to complete. This can take a few minutes depending on your internet speed.

  • Once the installation finishes, verify it worked by running:
clang++ --version

✔️ I see a version number

You should see output starting with something like Apple clang version followed by a version number. This confirms your C++ compiler is ready.

ⓧ Command not found

If you see an error, the installation may not have completed.

  • Re-run xcode-select --install and make sure to click Install in the dialog that appears.
  • Wait for the installation to fully complete before trying clang++ --version again.

ⓧ Already installed

If you see a message saying the tools are already installed, you are all set. Run clang++ --version to confirm the compiler is available.

Create the project directory structure

A well-organized directory structure keeps your headers, source files, tests, and build artifacts separated.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and all subdirectories by running these commands:
mkdir health-monitor
cd health-monitor
mkdir src include tests build lib

What do these folders do?

  • src/ holds your application source files (main.cpp, benchmark.cpp).
  • include/ holds your header files with class definitions and interfaces.
  • tests/ holds your unit test files.
  • build/ is where compiled binaries will be placed.
  • lib/ holds third-party libraries like Catch2.
  • Confirm the folders exist by running:
ls

You should see build, include, lib, src, and tests listed in the output.

Don't see all five folders?

Make sure you ran mkdir src include tests build lib as a single command with all five folder names separated by spaces.

If you missed one, create it individually (e.g. mkdir lib). Help me fix my project directory structure.

Download the Catch2 test framework

Catch2 is a C++ testing framework that ships as two amalgamated files. You download them directly into your lib/ folder.

  • Download the Catch2 v3.15.0 header file by running this command:
curl -L -o lib/catch_amalgamated.hpp https://github.com/catchorg/Catch2/releases/download/v3.15.0/catch_amalgamated.hpp
  • Download the Catch2 implementation file by running this command:
curl -L -o lib/catch_amalgamated.cpp https://github.com/catchorg/Catch2/releases/download/v3.15.0/catch_amalgamated.cpp

Why two files?

Catch2 v3 is distributed as an amalgamated pair. The catch_amalgamated.hpp file contains all the declarations your test files include.

The catch_amalgamated.cpp file contains the compiled implementation. You compile it alongside your tests so the test runner knows how to execute them.

  • Verify both files downloaded successfully by running:
ls lib/

You should see catch_amalgamated.cpp and catch_amalgamated.hpp listed.

Files not appearing in lib/?

Make sure you are in the health-monitor directory when running the curl commands. Run pwd to check your current directory.

If the downloads failed, check your internet connection and try the curl commands again. Help me troubleshoot my Catch2 download.

  • Open your project in VS Code by running:
code .

VS Code not opening?

If the code command is not recognized, open VS Code manually. Press Cmd+Space, type Visual Studio Code, and press Enter.

Then click File in the top menu bar, click Open Folder, and select the health-monitor folder on your Desktop.

Your development environment is ready. Next up, you will build a custom ring buffer data structure that forms the backbone of your health monitor.

Build the Ring Buffer Data Structure

Your project directory is ready. Now it is time to build the core data structure that powers the entire monitoring engine.

A ring buffer is a fixed-size array that wraps around when it fills up. Instead of growing endlessly or shifting elements, it simply overwrites the oldest entry. This makes it perfect for real-time monitoring where you only care about the most recent window of data.

In this step, get ready to:
  • Implement a templated RingBuffer class with circular storage.
  • Create a Makefile with C++17 compilation flags.
  • Verify the project compiles successfully.
Implement the RingBuffer class

The RingBuffer is a template class. It works with any data type and any fixed capacity, all determined at compile time. The key insight is the modulo arithmetic that makes the array circular.

  • In VS Code, create a new file called ring_buffer.hpp inside the include/ folder.
  • Paste the following code into ring_buffer.hpp:
#ifndef RING_BUFFER_HPP
#define RING_BUFFER_HPP

#include <array>
#include <cstddef>
#include <stdexcept>

template <typename T, std::size_t Capacity>
class RingBuffer {
public:
    RingBuffer() : head_(0), size_(0) {}

    void push(const T& item) {
        // Write to the current head position
        buffer_[head_] = item;
        // Advance head, wrapping around using modulo
        head_ = (head_ + 1) % Capacity;
        // Only grow size until we reach capacity
        if (size_ < Capacity) {
            ++size_;
        }
    }

    const T& operator[](std::size_t index) const {
        if (index >= size_) {
            throw std::out_of_range("RingBuffer index out of range");
        }
        // Calculate the actual position in the circular array
        std::size_t actual = (head_ + Capacity - size_ + index) % Capacity;
        return buffer_[actual];
    }

    std::size_t size() const { return size_; }
    std::size_t capacity() const { return Capacity; }
    bool empty() const { return size_ == 0; }
    bool full() const { return size_ == Capacity; }

    void clear() {
        head_ = 0;
        size_ = 0;
    }

    // Iterator support for range-based for loops
    class Iterator {
    public:
        Iterator(const RingBuffer* buf, std::size_t pos) : buf_(buf), pos_(pos) {}
        const T& operator*() const { return (*buf_)[pos_]; }
        Iterator& operator++() { ++pos_; return *this; }
        bool operator!=(const Iterator& other) const { return pos_ != other.pos_; }
    private:
        const RingBuffer* buf_;
        std::size_t pos_;
    };

    Iterator begin() const { return Iterator(this, 0); }
    Iterator end() const { return Iterator(this, size_); }

private:
    std::array<T, Capacity> buffer_;
    std::size_t head_;
    std::size_t size_;
};

#endif // RING_BUFFER_HPP

What does this code do?

The RingBuffer class uses a std::array for storage and two tracking variables: head_ (where the next write goes) and size_ (how many elements are currently stored).

  • push() writes to head_, then advances it with modulo. Once the buffer is full, new data silently overwrites the oldest entry.
  • operator[] translates a logical index (0 = oldest) into the actual circular position. It throws an exception if the index is out of bounds.
  • The nested Iterator class lets you use range-based for loops (for (const auto& x : buffer)) to traverse all stored elements from oldest to newest.
  • Save include/ring_buffer.hpp.
  • Confirm the file exists by running:
ls include/

You should see ring_buffer.hpp listed in the output.

Don't see ring_buffer.hpp?

  • Make sure you saved the file inside the include/ directory, not the project root.
  • Check that the filename is exactly ring_buffer.hpp (underscores, not hyphens).

Help me figure out why my ring_buffer.hpp file isn't showing up in the include/ directory.

Why a ring buffer instead of std::vector?

A std::vector grows dynamically and would let old metrics accumulate forever. A ring buffer enforces a fixed memory footprint. It guarantees O(1) insertion with no allocations, which is critical for high-throughput monitoring pipelines.

Create the Makefile and verify the build

A Makefile automates your compilation commands so you do not have to type long clang++ invocations every time. It defines targets (like monitor, benchmark, tests) that you can build individually.

  • Create a new file called Makefile in your project root (the same level as src/ and include/).
  • Paste the following into Makefile:
CXX = clang++
CXXFLAGS = -std=c++17 -Wall -Wextra -Iinclude
OPTFLAGS = -O2

SRC_DIR = src
TEST_DIR = tests
LIB_DIR = lib
BUILD_DIR = build

.PHONY: all clean monitor benchmark tests monitor-opt benchmark-opt

all: monitor benchmark tests

$(BUILD_DIR):
	mkdir -p $(BUILD_DIR)

monitor: $(BUILD_DIR)
	$(CXX) $(CXXFLAGS) $(SRC_DIR)/main.cpp -o $(BUILD_DIR)/monitor

monitor-opt: $(BUILD_DIR)
	$(CXX) $(CXXFLAGS) $(OPTFLAGS) $(SRC_DIR)/main.cpp -o $(BUILD_DIR)/monitor-opt

benchmark: $(BUILD_DIR)
	$(CXX) $(CXXFLAGS) $(SRC_DIR)/benchmark.cpp -o $(BUILD_DIR)/benchmark

benchmark-opt: $(BUILD_DIR)
	$(CXX) $(CXXFLAGS) $(OPTFLAGS) $(SRC_DIR)/benchmark.cpp -o $(BUILD_DIR)/benchmark-opt

tests: $(BUILD_DIR)
	$(CXX) $(CXXFLAGS) $(TEST_DIR)/test_main.cpp $(LIB_DIR)/catch_amalgamated.cpp -I$(LIB_DIR) -o $(BUILD_DIR)/tests

clean:
	rm -rf $(BUILD_DIR)

What do these Makefile targets do?

  • monitor compiles the main health monitor application.
  • monitor-opt compiles the same app with -O2 optimization. You will compare performance later.
  • benchmark and benchmark-opt compile the performance benchmarking harness (unoptimized and optimized).
  • tests compiles the Catch2 test suite.
  • clean removes the entire build/ directory for a fresh start.

Tabs vs spaces in Makefiles

Makefile recipe lines (the indented commands under each target) MUST use a real tab character, not spaces. If you pasted the code and see errors about missing separators, check that each indented line starts with a tab.

The monitor target requires src/main.cpp to exist. You will build the full main.cpp later, but for now a minimal placeholder lets you verify compilation works end to end.

  • Create a file called main.cpp inside the src/ folder and paste this placeholder:
#include "ring_buffer.hpp"

// Temporary placeholder to verify the build
int main() {
    RingBuffer<int, 10> buf;
    buf.push(1);
    return 0;
}

Why this placeholder?

This stub instantiates a RingBuffer<int, 10> and calls push(). If the template has any typos or missing includes, the compiler will catch them now. You will replace this file with the full monitor in a later step.

  • Save both Makefile and src/main.cpp.
  • Compile the project by running:
make monitor

You should see make echo the mkdir and clang++ commands it runs. If compilation succeeds, there is no error output and you return to your prompt.

  • Confirm the compiled binary exists by running:
ls build/

You should see monitor listed in the output.

Seeing a compilation error?

  • If you see *** missing separator, your Makefile uses spaces instead of tabs on recipe lines. Replace the leading spaces with a single tab character on each indented line.
  • If you see fatal error: 'ring_buffer.hpp' file not found, check that the file is inside include/ and named exactly ring_buffer.hpp.
  • If you see No rule to make target, make sure src/main.cpp exists and is saved.

Help me debug this C++ compilation error from my Makefile build.

✔️ Awesome, I've got everything!

Great. Make sure both files are saved before moving on.

ⓧ I'd like to double check the full code

Here is the complete include/ring_buffer.hpp:

#ifndef RING_BUFFER_HPP
#define RING_BUFFER_HPP

#include <array>
#include <cstddef>
#include <stdexcept>

template <typename T, std::size_t Capacity>
class RingBuffer {
public:
    RingBuffer() : head_(0), size_(0) {}

    void push(const T& item) {
        buffer_[head_] = item;
        head_ = (head_ + 1) % Capacity;
        if (size_ < Capacity) {
            ++size_;
        }
    }

    const T& operator[](std::size_t index) const {
        if (index >= size_) {
            throw std::out_of_range("RingBuffer index out of range");
        }
        std::size_t actual = (head_ + Capacity - size_ + index) % Capacity;
        return buffer_[actual];
    }

    std::size_t size() const { return size_; }
    std::size_t capacity() const { return Capacity; }
    bool empty() const { return size_ == 0; }
    bool full() const { return size_ == Capacity; }

    void clear() {
        head_ = 0;
        size_ = 0;
    }

    // Iterator support for range-based for loops
    class Iterator {
    public:
        Iterator(const RingBuffer* buf, std::size_t pos) : buf_(buf), pos_(pos) {}
        const T& operator*() const { return (*buf_)[pos_]; }
        Iterator& operator++() { ++pos_; return *this; }
        bool operator!=(const Iterator& other) const { return pos_ != other.pos_; }
    private:
        const RingBuffer* buf_;
        std::size_t pos_;
    };

    Iterator begin() const { return Iterator(this, 0); }
    Iterator end() const { return Iterator(this, size_); }

private:
    std::array<T, Capacity> buffer_;
    std::size_t head_;
    std::size_t size_;
};

#endif // RING_BUFFER_HPP

Here is the complete Makefile:

CXX = clang++
CXXFLAGS = -std=c++17 -Wall -Wextra -Iinclude
OPTFLAGS = -O2

SRC_DIR = src
TEST_DIR = tests
LIB_DIR = lib
BUILD_DIR = build

.PHONY: all clean monitor benchmark tests monitor-opt benchmark-opt

all: monitor benchmark tests

$(BUILD_DIR):
	mkdir -p $(BUILD_DIR)

monitor: $(BUILD_DIR)
	$(CXX) $(CXXFLAGS) $(SRC_DIR)/main.cpp -o $(BUILD_DIR)/monitor

monitor-opt: $(BUILD_DIR)
	$(CXX) $(CXXFLAGS) $(OPTFLAGS) $(SRC_DIR)/main.cpp -o $(BUILD_DIR)/monitor-opt

benchmark: $(BUILD_DIR)
	$(CXX) $(CXXFLAGS) $(SRC_DIR)/benchmark.cpp -o $(BUILD_DIR)/benchmark

benchmark-opt: $(BUILD_DIR)
	$(CXX) $(CXXFLAGS) $(OPTFLAGS) $(SRC_DIR)/benchmark.cpp -o $(BUILD_DIR)/benchmark-opt

tests: $(BUILD_DIR)
	$(CXX) $(CXXFLAGS) $(TEST_DIR)/test_main.cpp $(LIB_DIR)/catch_amalgamated.cpp -I$(LIB_DIR) -o $(BUILD_DIR)/tests

clean:
	rm -rf $(BUILD_DIR)

Your ring buffer is built and compiling cleanly. Next up, you will layer a statistics engine on top of it to compute rolling means, standard deviations, and z-scores over incoming metric streams.

Implement the Sliding Window Statistics Engine

Your RingBuffer gives you a fixed-capacity container with O(1) push and automatic overwrite of stale data. Now you need something to put in that buffer and a way to extract statistical meaning from it.

In this step, you will define the data types your monitor works with (metric events) and build a statistics engine on top of the RingBuffer. The engine computes rolling mean, standard deviation, and z-scores over a configurable time window.

In this step, get ready to:
  • Create data types for metric events and anomaly alerts.
  • Build a sliding window statistics engine using your RingBuffer.
  • Add a z-score calculation that measures how abnormal a new value is.
Create the metric event data types

Before your engine can process anything, it needs a shared vocabulary for the data flowing through it. A metric event represents a single measurement (CPU usage, memory, latency) captured at a point in time.

  • Create a new file called metric_event.hpp inside the include/ folder, then add the following code:
#ifndef METRIC_EVENT_HPP
#define METRIC_EVENT_HPP

#include <string>
#include <chrono>

struct MetricEvent {
    std::chrono::steady_clock::time_point timestamp;
    std::string metric_name;
    double value;
};

struct AnomalyAlert {
    MetricEvent event;
    double z_score;
    std::string severity; // "warning" or "critical"
};

#endif // METRIC_EVENT_HPP

What does this code do?

This header defines two lightweight structs that every other module in the project will share.

  • MetricEvent captures one measurement: a high-resolution timestamp, a name identifying the metric (like cpu_usage), and the numeric value.
  • AnomalyAlert wraps an event that tripped the anomaly detector, carrying its z-score and a severity label.
  • std::chrono::steady_clock::time_point provides nanosecond-precision monotonic timing, ideal for measuring intervals without clock drift.
  • Save include/metric_event.hpp.
  • You won't see a change yet. The StatisticsEngine you build next will include this file.

Worried about typos in the structs?

Double-check that the header guard names match exactly: METRIC_EVENT_HPP at the top and bottom. A mismatch causes subtle duplicate-definition errors later.

help me check my metric_event.hpp for typos

✔️ Awesome, I've got everything!

Great. Make sure you have saved include/metric_event.hpp before moving on.

ⓧ I'd like to double check the full code

#ifndef METRIC_EVENT_HPP
#define METRIC_EVENT_HPP

#include <string>
#include <chrono>

struct MetricEvent {
    std::chrono::steady_clock::time_point timestamp;
    std::string metric_name;
    double value;
};

struct AnomalyAlert {
    MetricEvent event;
    double z_score;
    std::string severity; // "warning" or "critical"
};

#endif // METRIC_EVENT_HPP
Build the sliding window statistics engine

A sliding window limits analysis to recent data. Old events that fall outside the window duration are discarded, keeping computations focused on current system behaviour.

  • Create a new file called statistics_engine.hpp inside the include/ folder, then add the following code:
#ifndef STATISTICS_ENGINE_HPP
#define STATISTICS_ENGINE_HPP

#include "ring_buffer.hpp"
#include "metric_event.hpp"
#include <cmath>
#include <chrono>

class StatisticsEngine {
public:
    static constexpr std::size_t MAX_WINDOW_SIZE = 1000;

    explicit StatisticsEngine(std::chrono::seconds window_duration)
        : window_duration_(window_duration) {}

    void addEvent(const MetricEvent& event) {
        pruneExpired(event.timestamp);
        buffer_.push(event);
    }

    double mean() const {
        if (buffer_.empty()) return 0.0;
        double sum = 0.0;
        for (const auto& e : buffer_) {
            sum += e.value;
        }
        return sum / static_cast<double>(buffer_.size());
    }

    double stddev() const {
        if (buffer_.size() < 2) return 0.0;
        double m = mean();
        double sum_sq = 0.0;
        for (const auto& e : buffer_) {
            double diff = e.value - m;
            sum_sq += diff * diff;
        }
        return std::sqrt(sum_sq / static_cast<double>(buffer_.size()));
    }

    std::size_t count() const { return buffer_.size(); }

private:
    void pruneExpired(std::chrono::steady_clock::time_point now) {
        // Ring buffer overwrites oldest automatically when full.
        // For time-based expiry we rebuild with only valid events.
        RingBuffer<MetricEvent, MAX_WINDOW_SIZE> fresh;
        for (const auto& e : buffer_) {
            if ((now - e.timestamp) <= window_duration_) {
                fresh.push(e);
            }
        }
        buffer_ = fresh;
    }

    RingBuffer<MetricEvent, MAX_WINDOW_SIZE> buffer_;
    std::chrono::seconds window_duration_;
};

#endif // STATISTICS_ENGINE_HPP

What does this code do?

This class turns your raw RingBuffer into a time-aware analytics engine.

  • addEvent() first prunes expired events, then pushes the new event into the buffer.
  • mean() iterates over all events in the window and returns the arithmetic average of their values.
  • stddev() computes population standard deviation. It measures how spread out the values are from the mean.
  • pruneExpired() rebuilds the buffer keeping only events within the window_duration_ time span. This is what makes the window "slide."

Why rebuild instead of popping from the front?

The RingBuffer has no pop operation by design. It is optimized for append-only throughput. Rebuilding a fresh buffer with only valid events is simple, correct, and fast enough for a 1000-element window.

  • Save include/statistics_engine.hpp.
  • You won't see a change yet. The next substep adds the final calculation method, then we verify everything compiles.

Seeing red squiggles in your editor?

Make sure metric_event.hpp is saved in the same include/ folder. The #include "metric_event.hpp" at the top of this file looks for it relative to the include path.

Also confirm ring_buffer.hpp is still present in include/ from the previous step.

help me fix include errors in statistics_engine.hpp

Add the z-score calculation

A z-score tells you how many standard deviations a value sits from the mean. A z-score of 3.0 means the value is three standard deviations above average. That is a strong anomaly signal.

  • In include/statistics_engine.hpp, find the count() method:
    std::size_t count() const { return buffer_.size(); }
  • Add the following zScore() method directly above the count() line:
    double zScore(double value) const {
        double s = stddev();
        if (s == 0.0) return 0.0;
        return (value - mean()) / s;
    }

What does this code do?

zScore() takes a raw metric value and returns how unusual it is relative to the current window.

  • It computes (value - mean) / stddev. A result near 0 means the value is typical. A result above 2.0 or below -2.0 is suspicious.
  • The guard if (s == 0.0) return 0.0 prevents division by zero when all values in the window are identical.
  • Save include/statistics_engine.hpp.
  • Verify both headers compile cleanly by running this command:
clang++ -std=c++17 -Iinclude -fsyntax-only -x c++-header include/statistics_engine.hpp

If the command produces no output, your code is syntactically correct.

Seeing a compile error?

Common causes of syntax errors at this stage:

  • A missing semicolon after a closing brace }; at the end of the class or struct.
  • A typo in std::chrono::steady_clock::time_point. Copy it exactly from the code above.
  • The zScore() method placed outside the public: section. It must sit between stddev() and count().

help me fix my statistics_engine.hpp compile error

✔️ Awesome, I've got everything!

Great. Confirm you have saved both include/metric_event.hpp and include/statistics_engine.hpp.

ⓧ I'd like to double check the full code

#ifndef METRIC_EVENT_HPP
#define METRIC_EVENT_HPP

#include <string>
#include <chrono>

struct MetricEvent {
    std::chrono::steady_clock::time_point timestamp;
    std::string metric_name;
    double value;
};

struct AnomalyAlert {
    MetricEvent event;
    double z_score;
    std::string severity; // "warning" or "critical"
};

#endif // METRIC_EVENT_HPP
#ifndef STATISTICS_ENGINE_HPP
#define STATISTICS_ENGINE_HPP

#include "ring_buffer.hpp"
#include "metric_event.hpp"
#include <cmath>
#include <chrono>

class StatisticsEngine {
public:
    static constexpr std::size_t MAX_WINDOW_SIZE = 1000;

    explicit StatisticsEngine(std::chrono::seconds window_duration)
        : window_duration_(window_duration) {}

    void addEvent(const MetricEvent& event) {
        pruneExpired(event.timestamp);
        buffer_.push(event);
    }

    double mean() const {
        if (buffer_.empty()) return 0.0;
        double sum = 0.0;
        for (const auto& e : buffer_) {
            sum += e.value;
        }
        return sum / static_cast<double>(buffer_.size());
    }

    double stddev() const {
        if (buffer_.size() < 2) return 0.0;
        double m = mean();
        double sum_sq = 0.0;
        for (const auto& e : buffer_) {
            double diff = e.value - m;
            sum_sq += diff * diff;
        }
        return std::sqrt(sum_sq / static_cast<double>(buffer_.size()));
    }

    double zScore(double value) const {
        double s = stddev();
        if (s == 0.0) return 0.0;
        return (value - mean()) / s;
    }

    std::size_t count() const { return buffer_.size(); }

private:
    void pruneExpired(std::chrono::steady_clock::time_point now) {
        // Ring buffer overwrites oldest automatically when full.
        // For time-based expiry we rebuild with only valid events.
        RingBuffer<MetricEvent, MAX_WINDOW_SIZE> fresh;
        for (const auto& e : buffer_) {
            if ((now - e.timestamp) <= window_duration_) {
                fresh.push(e);
            }
        }
        buffer_ = fresh;
    }

    RingBuffer<MetricEvent, MAX_WINDOW_SIZE> buffer_;
    std::chrono::seconds window_duration_;
};

#endif // STATISTICS_ENGINE_HPP

Your statistics engine is ready to crunch numbers. Next up, you will wrap it in an anomaly detection module that makes real-time pass/fail decisions on incoming events.

Build the Anomaly Detection Module

Your StatisticsEngine can compute rolling means, standard deviations, and z-scores over a time window.

Now you need a decision layer on top of it. The AnomalyDetector class will wrap the StatisticsEngine, apply configurable z-score thresholds, and return structured alerts when a metric value deviates too far from normal. Then you will wire up a simulation that feeds realistic CPU data into the detector and watch it flag injected spikes.

In this step, get ready to:
  • Create an AnomalyDetector class with configurable warning and critical thresholds.
  • Simulate a stream of 100 CPU metric events with injected spikes.
  • Build and run the monitor to see anomalies detected in real time.
Create the AnomalyDetector class

A z-score tells you how many standard deviations a value is from the mean. A z-score of 2.0 means the value is unusually high (or low). A z-score of 3.0 means it is extremely unusual.

The AnomalyDetector uses two thresholds: a warning threshold (default 2.0) and a critical threshold (default 3.0). It waits until at least 10 data points are collected before making judgments, so it has a stable baseline to compare against.

  • Create a new file called anomaly_detector.hpp inside your include/ folder.
  • Copy the following code into the file:
#ifndef ANOMALY_DETECTOR_HPP
#define ANOMALY_DETECTOR_HPP

#include "statistics_engine.hpp"
#include "metric_event.hpp"
#include <optional>

class AnomalyDetector {
public:
    explicit AnomalyDetector(std::chrono::seconds window_duration,
                             double warning_threshold = 2.0,
                             double critical_threshold = 3.0)
        : engine_(window_duration),
          warning_threshold_(warning_threshold),
          critical_threshold_(critical_threshold) {}

    std::optional<AnomalyAlert> ingest(const MetricEvent& event) {
        // Need enough data points before detecting anomalies
        if (engine_.count() >= 10) {
            double z = engine_.zScore(event.value);
            if (std::abs(z) >= critical_threshold_) {
                AnomalyAlert alert{event, z, "critical"};
                engine_.addEvent(event);
                return alert;
            } else if (std::abs(z) >= warning_threshold_) {
                AnomalyAlert alert{event, z, "warning"};
                engine_.addEvent(event);
                return alert;
            }
        }
        engine_.addEvent(event);
        return std::nullopt;
    }

    std::size_t eventsProcessed() const { return engine_.count(); }

private:
    StatisticsEngine engine_;
    double warning_threshold_;
    double critical_threshold_;
};

#endif // ANOMALY_DETECTOR_HPP

What does this code do?

  • The constructor takes a time window duration and two threshold values, then initializes a StatisticsEngine internally.
  • The ingest() method checks whether enough data exists (10+ events) before computing a z-score. This prevents false positives when the baseline is too small.
  • If the absolute z-score exceeds the critical threshold, it returns a "critical" alert. If it only exceeds the warning threshold, it returns a "warning" alert.
  • If no anomaly is detected, it returns std::nullopt. This is a C++17 feature that represents "no value" inside a std::optional.
  • Save include/anomaly_detector.hpp.
  • You won't see output yet. The next substep creates a main.cpp that uses this class.

Seeing a red squiggly in your editor?

If your editor shows an error on #include <optional>, make sure your editor is configured for C++17 or later. The std::optional header is only available from C++17 onward.

If you see errors on statistics_engine.hpp or metric_event.hpp includes, double-check that those files exist in your include/ folder from the previous steps.

help me debug an include error in anomaly_detector.hpp

✔️ Awesome, I've got everything!

Great. Make sure you have saved the file before moving on.

ⓧ I'd like to double check the full code

#ifndef ANOMALY_DETECTOR_HPP
#define ANOMALY_DETECTOR_HPP

#include "statistics_engine.hpp"
#include "metric_event.hpp"
#include <optional>

class AnomalyDetector {
public:
    explicit AnomalyDetector(std::chrono::seconds window_duration,
                             double warning_threshold = 2.0,
                             double critical_threshold = 3.0)
        : engine_(window_duration),
          warning_threshold_(warning_threshold),
          critical_threshold_(critical_threshold) {}

    std::optional<AnomalyAlert> ingest(const MetricEvent& event) {
        if (engine_.count() >= 10) {
            double z = engine_.zScore(event.value);
            if (std::abs(z) >= critical_threshold_) {
                AnomalyAlert alert{event, z, "critical"};
                engine_.addEvent(event);
                return alert;
            } else if (std::abs(z) >= warning_threshold_) {
                AnomalyAlert alert{event, z, "warning"};
                engine_.addEvent(event);
                return alert;
            }
        }
        engine_.addEvent(event);
        return std::nullopt;
    }

    std::size_t eventsProcessed() const { return engine_.count(); }

private:
    StatisticsEngine engine_;
    double warning_threshold_;
    double critical_threshold_;
};

#endif // ANOMALY_DETECTOR_HPP
Write the event simulation in main

To prove that your anomaly detector works, you need a realistic stream of data. You will generate 100 CPU usage events drawn from a normal distribution (mean 50%, standard deviation 5%), then inject three deliberate spikes to trigger alerts.

  • Create a new file called main.cpp inside your src/ folder.
  • Copy the following code into the file:
#include "anomaly_detector.hpp"
#include "metric_event.hpp"
#include <iostream>
#include <vector>
#include <chrono>
#include <random>
#include <iomanip>

int main() {
    using namespace std::chrono;

    AnomalyDetector detector(seconds(60), 2.0, 3.0);

    // Simulate a stream of CPU usage metrics
    std::mt19937 rng(42);
    std::normal_distribution<double> normal_cpu(50.0, 5.0); // mean 50%, stddev 5%

    std::cout << "=== System Health Monitor ===" << std::endl;
    std::cout << "Ingesting 100 CPU metric events..." << std::endl;
    std::cout << std::string(40, '-') << std::endl;

    auto base_time = steady_clock::now();

    for (int i = 0; i < 100; ++i) {
        double value = normal_cpu(rng);

        // Inject spikes at events 50, 70, and 85
        if (i == 50) value = 95.0;
        if (i == 70) value = 88.0;
        if (i == 85) value = 10.0; // sudden drop

        MetricEvent event{
            base_time + milliseconds(i * 100),
            "cpu_usage",
            value
        };

        auto alert = detector.ingest(event);
        if (alert.has_value()) {
            std::cout << "[" << std::setw(8) << alert->severity << "] "
                      << "Event #" << i
                      << " | value: " << std::fixed << std::setprecision(1) << alert->event.value
                      << "% | z-score: " << std::setprecision(2) << alert->z_score
                      << std::endl;
        }
    }

    std::cout << std::string(40, '-') << std::endl;
    std::cout << "Total events processed: " << detector.eventsProcessed() << std::endl;
    std::cout << "Monitor complete." << std::endl;

    return 0;
}

What does this code do?

  • It creates an AnomalyDetector with a 60-second window, a warning threshold of 2.0, and a critical threshold of 3.0.
  • The std::mt19937 random number generator (seeded with 42 for reproducibility) feeds a normal distribution centered on 50% CPU usage.
  • Three deliberate spikes are injected: 95% at event 50, 88% at event 70, and a sudden drop to 10% at event 85.
  • Each event is passed to detector.ingest(). If it returns an alert, the severity, value, and z-score are printed.
  • Save src/main.cpp.
  • You won't see output yet. The next substep compiles and runs the program.

Editor showing errors in main.cpp?

If your editor flags #include "anomaly_detector.hpp", make sure anomaly_detector.hpp is saved in the include/ folder (not in src/).

If std::optional or std::mt19937 are underlined, your editor's language standard may not be set to C++17. The compiler will still handle it correctly when you build with the Makefile.

help me fix include path issues in my C++ project

✔️ Awesome, I've got everything!

Great. Make sure you have saved the file before moving on.

ⓧ I'd like to double check the full code

#include "anomaly_detector.hpp"
#include "metric_event.hpp"
#include <iostream>
#include <vector>
#include <chrono>
#include <random>
#include <iomanip>

int main() {
    using namespace std::chrono;

    AnomalyDetector detector(seconds(60), 2.0, 3.0);

    std::mt19937 rng(42);
    std::normal_distribution<double> normal_cpu(50.0, 5.0);

    std::cout << "=== System Health Monitor ===" << std::endl;
    std::cout << "Ingesting 100 CPU metric events..." << std::endl;
    std::cout << std::string(40, '-') << std::endl;

    auto base_time = steady_clock::now();

    for (int i = 0; i < 100; ++i) {
        double value = normal_cpu(rng);

        if (i == 50) value = 95.0;
        if (i == 70) value = 88.0;
        if (i == 85) value = 10.0;

        MetricEvent event{
            base_time + milliseconds(i * 100),
            "cpu_usage",
            value
        };

        auto alert = detector.ingest(event);
        if (alert.has_value()) {
            std::cout << "[" << std::setw(8) << alert->severity << "] "
                      << "Event #" << i
                      << " | value: " << std::fixed << std::setprecision(1) << alert->event.value
                      << "% | z-score: " << std::setprecision(2) << alert->z_score
                      << std::endl;
        }
    }

    std::cout << std::string(40, '-') << std::endl;
    std::cout << "Total events processed: " << detector.eventsProcessed() << std::endl;
    std::cout << "Monitor complete." << std::endl;

    return 0;
}
Build and run the monitor

Your Makefile already has a monitor target that compiles src/main.cpp with the -Iinclude flag, so the compiler knows where to find your header files.

  • Build the monitor by running this command:
make monitor
  • Run the compiled binary:
./build/monitor
  • You should see output that starts with === System Health Monitor === followed by a few alert lines showing detected anomalies with their severity, event number, value, and z-score.

What should I see?

The output prints only the events that triggered an anomaly alert. You should see alerts for the injected spikes near events 50, 70, and 85.

Each alert line shows [critical] or [warning] depending on how far the value deviated from the rolling mean. The z-score tells you exactly how many standard deviations away it was.

Build failed?

If you see an error about a missing file, confirm all four header files exist in your include/ folder: ring_buffer.hpp, metric_event.hpp, statistics_engine.hpp, and anomaly_detector.hpp.

If you see a linker or syntax error, check that your file contents match the code blocks above exactly. A missing semicolon or brace is the most common culprit.

If you get make: Nothing to be done, run make clean first, then try make monitor again.

help me fix a C++ build error in my system health monitor project

Your anomaly detection pipeline is working end-to-end. Next up, you will add performance benchmarks to measure how fast your ring buffer and detection pipeline can process events.

Add Performance Benchmarks

Your anomaly detection pipeline works. But how fast is it?

In production systems, throughput matters as much as correctness. A monitoring engine that processes 100 events per second is useless when thousands arrive every millisecond. In this step, you will build a benchmark harness that measures exactly how many operations your data structures and pipeline can handle per second.

In this step, get ready to:
  • Benchmark your RingBuffer's push throughput using std::chrono::steady_clock.
  • Benchmark the full ingest-to-detection pipeline.
  • Compare optimized vs unoptimized builds using the -O2 compiler flag.
Create the RingBuffer benchmark harness

The standard approach for microbenchmarking in C++ is to capture timestamps before and after a tight loop, then calculate operations per second from the elapsed time. std::chrono::steady_clock provides monotonic, nanosecond-precision timing that won't be affected by system clock adjustments.

  • Create a new file called benchmark.cpp inside your src/ folder.
  • Add the includes and the benchmarkRingBuffer() function by copying this code:
#include "ring_buffer.hpp"
#include "anomaly_detector.hpp"
#include "metric_event.hpp"
#include <iostream>
#include <chrono>
#include <iomanip>

void benchmarkRingBuffer() {
    using namespace std::chrono;

    RingBuffer<double, 1024> buffer;
    constexpr int iterations = 1000000;

    // Capture start time before the hot loop
    auto start = steady_clock::now();
    for (int i = 0; i < iterations; ++i) {
        buffer.push(static_cast<double>(i));
    }
    auto end = steady_clock::now();

    // Calculate throughput from elapsed nanoseconds
    auto duration_ns = duration_cast<nanoseconds>(end - start).count();
    double ops_per_sec = static_cast<double>(iterations) / (static_cast<double>(duration_ns) / 1e9);

    std::cout << "RingBuffer push benchmark:" << std::endl;
    std::cout << "  Iterations: " << iterations << std::endl;
    std::cout << "  Total time: " << duration_ns / 1000000.0 << " ms" << std::endl;
    std::cout << "  Ops/sec:    " << std::fixed << std::setprecision(0) << ops_per_sec << std::endl;
    std::cout << std::endl;
}

What does this code do?

This function runs a tight loop of 1,000,000 push() calls on a RingBuffer<double, 1024>. The buffer overflows and wraps many times during this loop, exercising the circular overwrite path.

The elapsed time is captured in nanoseconds, then converted to operations per second. This gives you a precise measure of how fast a single data structure operation completes.

  • Save src/benchmark.cpp.

Why std::chrono::steady_clock?

On Apple's libc++ (shipped with Xcode Command Line Tools), steady_clock has nanosecond precision and is guaranteed monotonic. It will never jump backwards due to system time adjustments, making it the right choice for measuring elapsed intervals.

Benchmark the full detection pipeline

The RingBuffer is only one piece of the system. The real question is how fast the entire ingest-to-detection pipeline performs when statistics computation and anomaly checking happen on every event.

  • In src/benchmark.cpp, add the benchmarkPipeline() function and main() below the benchmarkRingBuffer() function:
void benchmarkPipeline() {
    using namespace std::chrono;

    AnomalyDetector detector(seconds(60), 2.0, 3.0);
    constexpr int iterations = 100000;
    auto base_time = steady_clock::now();

    // Time the full ingest loop including statistics and z-score checks
    auto start = steady_clock::now();
    for (int i = 0; i < iterations; ++i) {
        MetricEvent event{
            base_time + milliseconds(i),
            "cpu",
            50.0 + (i % 10) * 0.5
        };
        detector.ingest(event);
    }
    auto end = steady_clock::now();

    auto duration_ns = duration_cast<nanoseconds>(end - start).count();
    double ops_per_sec = static_cast<double>(iterations) / (static_cast<double>(duration_ns) / 1e9);

    std::cout << "Full pipeline benchmark:" << std::endl;
    std::cout << "  Events:     " << iterations << std::endl;
    std::cout << "  Total time: " << duration_ns / 1000000.0 << " ms" << std::endl;
    std::cout << "  Events/sec: " << std::fixed << std::setprecision(0) << ops_per_sec << std::endl;
    std::cout << std::endl;
}

int main() {
    std::cout << "=== Performance Benchmarks ===" << std::endl;
    std::cout << std::string(40, '-') << std::endl;

    benchmarkRingBuffer();
    benchmarkPipeline();

    std::cout << "Benchmarks complete." << std::endl;
    return 0;
}

What does this code do?

  • benchmarkPipeline() creates a full AnomalyDetector and feeds it 100,000 events. Each event triggers sliding window pruning, mean/stddev calculation, and z-score checking.
  • The event values cycle through a pattern (50.0 + (i % 10) * 0.5) so they stay stable and don't trigger anomaly alerts. This isolates pure throughput from alert-handling overhead.
  • main() orchestrates both benchmarks and prints a header so the output reads cleanly.
  • Save src/benchmark.cpp.

Seeing a compile error?

Make sure benchmarkPipeline() is placed after the closing brace of benchmarkRingBuffer(). C++ requires functions to be declared or defined before they are called.

help me fix a compile error in my benchmark.cpp file

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

#include "ring_buffer.hpp"
#include "anomaly_detector.hpp"
#include "metric_event.hpp"
#include <iostream>
#include <chrono>
#include <iomanip>

void benchmarkRingBuffer() {
    using namespace std::chrono;

    RingBuffer<double, 1024> buffer;
    constexpr int iterations = 1000000;

    auto start = steady_clock::now();
    for (int i = 0; i < iterations; ++i) {
        buffer.push(static_cast<double>(i));
    }
    auto end = steady_clock::now();

    auto duration_ns = duration_cast<nanoseconds>(end - start).count();
    double ops_per_sec = static_cast<double>(iterations) / (static_cast<double>(duration_ns) / 1e9);

    std::cout << "RingBuffer push benchmark:" << std::endl;
    std::cout << "  Iterations: " << iterations << std::endl;
    std::cout << "  Total time: " << duration_ns / 1000000.0 << " ms" << std::endl;
    std::cout << "  Ops/sec:    " << std::fixed << std::setprecision(0) << ops_per_sec << std::endl;
    std::cout << std::endl;
}

void benchmarkPipeline() {
    using namespace std::chrono;

    AnomalyDetector detector(seconds(60), 2.0, 3.0);
    constexpr int iterations = 100000;
    auto base_time = steady_clock::now();

    auto start = steady_clock::now();
    for (int i = 0; i < iterations; ++i) {
        MetricEvent event{
            base_time + milliseconds(i),
            "cpu",
            50.0 + (i % 10) * 0.5
        };
        detector.ingest(event);
    }
    auto end = steady_clock::now();

    auto duration_ns = duration_cast<nanoseconds>(end - start).count();
    double ops_per_sec = static_cast<double>(iterations) / (static_cast<double>(duration_ns) / 1e9);

    std::cout << "Full pipeline benchmark:" << std::endl;
    std::cout << "  Events:     " << iterations << std::endl;
    std::cout << "  Total time: " << duration_ns / 1000000.0 << " ms" << std::endl;
    std::cout << "  Events/sec: " << std::fixed << std::setprecision(0) << ops_per_sec << std::endl;
    std::cout << std::endl;
}

int main() {
    std::cout << "=== Performance Benchmarks ===" << std::endl;
    std::cout << std::string(40, '-') << std::endl;

    benchmarkRingBuffer();
    benchmarkPipeline();

    std::cout << "Benchmarks complete." << std::endl;
    return 0;
}
Run and compare optimized vs unoptimized builds

Your Makefile already has two benchmark targets. The benchmark target compiles without optimization. The benchmark-opt target adds the -O2 flag, which tells the compiler to apply aggressive optimizations like loop unrolling and inlining.

  • Build the unoptimized benchmark by running:
make benchmark
  • Run the unoptimized benchmark by running:
./build/benchmark

You should see output showing the RingBuffer ops/sec and the pipeline events/sec. Note both numbers down for comparison.

  • Now build the optimized version by running:
make benchmark-opt
  • Run the optimized benchmark by running:
./build/benchmark-opt

Compare the two sets of numbers. The optimized build should show significantly higher throughput for both the RingBuffer and the full pipeline.

What does -O2 actually do?

The -O2 flag enables compiler optimizations like function inlining, loop unrolling, and dead code elimination. These transformations preserve program behavior but restructure the machine code to execute fewer instructions per iteration.

You should see the RingBuffer benchmark exceed 100,000,000 ops/sec in the optimized build. The pipeline benchmark will be slower (due to statistics computation on every event) but should still clear 100,000+ events/sec.

Seeing 'No such file or directory' when running?

Make sure the make command completed without errors before trying to run the binary. If the build failed, check the compiler output for missing includes or typos in your src/benchmark.cpp file.

Also confirm you are running the command from your project's root folder (the one containing the Makefile).

help me troubleshoot why my benchmark binary isn't found

You now have quantitative proof that your data structures perform at production-grade throughput. Next up, you will write a test suite to verify correctness across edge cases.

Write Unit Tests with Catch2

Your monitor detects anomalies and your benchmarks prove it's fast. But how do you know the underlying logic is actually correct?

In this step, you'll write a comprehensive test suite using Catch2 to validate that your ring buffer wraps correctly, your statistics are mathematically accurate, and your anomaly detector fires only when it should.

In this step, get ready to:
  • Write TEST_CASEs validating RingBuffer behavior.
  • Write TEST_CASEs for StatisticsEngine and AnomalyDetector.
  • Build and run the full test suite.
Write RingBuffer test cases

The RingBuffer is the foundation of your entire pipeline. If its circular overwrite logic has a subtle bug, every metric that passes through it will be wrong. Tests catch these issues before they cascade.

  • Create a new file called test_main.cpp inside your tests/ directory.
  • Add the file's includes and the RingBuffer test cases by copying this code into it:
#include "catch_amalgamated.hpp"
#include "../include/ring_buffer.hpp"
#include "../include/statistics_engine.hpp"
#include "../include/anomaly_detector.hpp"
#include "../include/metric_event.hpp"
#include <chrono>

using namespace std::chrono;

// --- RingBuffer Tests ---

TEST_CASE("RingBuffer stores and retrieves elements", "[ring_buffer]") {
    RingBuffer<int, 5> buf;

    SECTION("starts empty") {
        REQUIRE(buf.empty());
        REQUIRE(buf.size() == 0);
        REQUIRE(buf.capacity() == 5);
    }

    SECTION("push increases size") {
        buf.push(10);
        buf.push(20);
        REQUIRE(buf.size() == 2);
        REQUIRE(buf[0] == 10);
        REQUIRE(buf[1] == 20);
    }

    SECTION("wraps around when full") {
        for (int i = 0; i < 7; ++i) {
            buf.push(i);
        }
        REQUIRE(buf.size() == 5);
        REQUIRE(buf.full());
        // Should contain elements 2,3,4,5,6
        REQUIRE(buf[0] == 2);
        REQUIRE(buf[4] == 6);
    }
}

TEST_CASE("RingBuffer iterator works", "[ring_buffer]") {
    RingBuffer<int, 4> buf;
    buf.push(1);
    buf.push(2);
    buf.push(3);

    int sum = 0;
    for (const auto& val : buf) {
        sum += val;
    }
    REQUIRE(sum == 6);
}

What do these tests cover?

The first TEST_CASE uses Catch2's SECTION macro to run three independent scenarios against the same buffer. Each SECTION starts fresh from the TEST_CASE setup.

  • "starts empty" confirms the buffer initializes with zero elements.
  • "push increases size" checks that elements are retrievable in insertion order.
  • "wraps around when full" pushes 7 elements into a size-5 buffer and verifies the oldest two are gone.
  • The iterator test confirms range-based for loops traverse all stored elements correctly.
Write StatisticsEngine and AnomalyDetector test cases

Now you'll test the higher-level modules that depend on the ring buffer. These tests verify that your math is correct and that the anomaly detector only fires on genuine outliers.

  • In tests/test_main.cpp, add the StatisticsEngine test cases below the RingBuffer tests:
// --- StatisticsEngine Tests ---

TEST_CASE("StatisticsEngine computes correct mean", "[statistics]") {
    StatisticsEngine engine(seconds(60));
    auto now = steady_clock::now();

    engine.addEvent({now, "cpu", 10.0});
    engine.addEvent({now + seconds(1), "cpu", 20.0});
    engine.addEvent({now + seconds(2), "cpu", 30.0});

    REQUIRE(engine.mean() == Catch::Approx(20.0));
}

TEST_CASE("StatisticsEngine computes correct stddev", "[statistics]") {
    StatisticsEngine engine(seconds(60));
    auto now = steady_clock::now();

    // Values: 10, 20, 30. Mean = 20. Variance = (100+0+100)/3 = 66.67
    engine.addEvent({now, "cpu", 10.0});
    engine.addEvent({now + seconds(1), "cpu", 20.0});
    engine.addEvent({now + seconds(2), "cpu", 30.0});

    REQUIRE(engine.stddev() == Catch::Approx(8.165).epsilon(0.01));
}

TEST_CASE("StatisticsEngine z-score identifies outliers", "[statistics]") {
    StatisticsEngine engine(seconds(60));
    auto now = steady_clock::now();

    // Build baseline: 20 events around 50.0
    for (int i = 0; i < 20; ++i) {
        engine.addEvent({now + seconds(i), "cpu", 50.0 + (i % 3)});
    }

    // A value of 100 should have a high z-score
    double z = engine.zScore(100.0);
    REQUIRE(z > 2.0);
}

What does this code do?

  • Catch::Approx(20.0) handles floating-point comparison. Instead of requiring exact equality (which fails due to precision), it allows a small margin.
  • .epsilon(0.01) sets a 1% relative tolerance for the standard deviation test, since the exact value 8.16496... would be fragile to assert.
  • The z-score test builds a stable baseline of 20 events near 50.0, then asks whether 100.0 would be flagged as an outlier. A z-score above 2.0 confirms it would.
  • Below the StatisticsEngine tests, add the AnomalyDetector test cases:
// --- AnomalyDetector Tests ---

TEST_CASE("AnomalyDetector does not alert on stable data", "[anomaly]") {
    AnomalyDetector detector(seconds(60), 2.0, 3.0);
    auto now = steady_clock::now();

    // Feed 50 stable events
    for (int i = 0; i < 50; ++i) {
        MetricEvent event{now + seconds(i), "cpu", 50.0};
        auto alert = detector.ingest(event);
        REQUIRE_FALSE(alert.has_value());
    }
}

TEST_CASE("AnomalyDetector flags spike as anomaly", "[anomaly]") {
    AnomalyDetector detector(seconds(60), 2.0, 3.0);
    auto now = steady_clock::now();

    // Build baseline
    for (int i = 0; i < 20; ++i) {
        detector.ingest({now + seconds(i), "cpu", 50.0});
    }

    // Inject spike
    MetricEvent spike{now + seconds(21), "cpu", 200.0};
    auto alert = detector.ingest(spike);

    REQUIRE(alert.has_value());
    REQUIRE(alert->severity == "critical");
}

What does this code do?

  • The "stable data" test feeds 50 identical events and asserts that NONE of them trigger an alert. This is a false-positive check.
  • REQUIRE_FALSE(alert.has_value()) asserts that the optional is empty.
  • The "spike" test builds a 20-event baseline at 50.0, then injects a value of 200.0. That is far enough from the mean to exceed the critical threshold of 3.0 standard deviations.
  • Save tests/test_main.cpp.
Build and run the test suite

Your Makefile already has a tests target that compiles test_main.cpp alongside the Catch2 amalgamated source. Time to build and run it.

  • Build the test binary by running:
make tests

What does this command do?

This compiles your test file and the Catch2 amalgamated source together into a single ./build/tests binary. Catch2's amalgamated build takes a bit longer than your other targets because it includes the entire test framework in one compilation unit.

  • Run the test suite by executing the built binary:
./build/tests
  • You should see output ending with a summary line like All tests passed followed by the assertion and test case counts.

Seeing compilation errors?

  • Check that your lib/ directory contains both catch_amalgamated.hpp and catch_amalgamated.cpp. Both files are needed for the amalgamated build.
  • Verify the include path in your test file uses "catch_amalgamated.hpp" (not angle brackets). The Makefile's -I$(LIB_DIR) flag tells the compiler where to find it.
  • If you see linker errors about undefined references, make sure you did not accidentally remove the relative paths (../include/) from the project header includes in your test file.

Help me debug my Catch2 test compilation errors.

Tests failing?

  • Catch2 shows the exact line and values that failed. Read the assertion output carefully to see which expected value mismatched.
  • If the stddev test fails, double-check that your StatisticsEngine::stddev() divides by buffer_.size() (population stddev), not buffer_.size() - 1 (sample stddev).

Help me understand why my test assertions are failing.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file.

ⓧ I'd like to double check the full code

#include "catch_amalgamated.hpp"
#include "../include/ring_buffer.hpp"
#include "../include/statistics_engine.hpp"
#include "../include/anomaly_detector.hpp"
#include "../include/metric_event.hpp"
#include <chrono>

using namespace std::chrono;

// --- RingBuffer Tests ---

TEST_CASE("RingBuffer stores and retrieves elements", "[ring_buffer]") {
    RingBuffer<int, 5> buf;

    SECTION("starts empty") {
        REQUIRE(buf.empty());
        REQUIRE(buf.size() == 0);
        REQUIRE(buf.capacity() == 5);
    }

    SECTION("push increases size") {
        buf.push(10);
        buf.push(20);
        REQUIRE(buf.size() == 2);
        REQUIRE(buf[0] == 10);
        REQUIRE(buf[1] == 20);
    }

    SECTION("wraps around when full") {
        for (int i = 0; i < 7; ++i) {
            buf.push(i);
        }
        REQUIRE(buf.size() == 5);
        REQUIRE(buf.full());
        // Should contain elements 2,3,4,5,6
        REQUIRE(buf[0] == 2);
        REQUIRE(buf[4] == 6);
    }
}

TEST_CASE("RingBuffer iterator works", "[ring_buffer]") {
    RingBuffer<int, 4> buf;
    buf.push(1);
    buf.push(2);
    buf.push(3);

    int sum = 0;
    for (const auto& val : buf) {
        sum += val;
    }
    REQUIRE(sum == 6);
}

// --- StatisticsEngine Tests ---

TEST_CASE("StatisticsEngine computes correct mean", "[statistics]") {
    StatisticsEngine engine(seconds(60));
    auto now = steady_clock::now();

    engine.addEvent({now, "cpu", 10.0});
    engine.addEvent({now + seconds(1), "cpu", 20.0});
    engine.addEvent({now + seconds(2), "cpu", 30.0});

    REQUIRE(engine.mean() == Catch::Approx(20.0));
}

TEST_CASE("StatisticsEngine computes correct stddev", "[statistics]") {
    StatisticsEngine engine(seconds(60));
    auto now = steady_clock::now();

    // Values: 10, 20, 30. Mean = 20. Variance = (100+0+100)/3 = 66.67
    engine.addEvent({now, "cpu", 10.0});
    engine.addEvent({now + seconds(1), "cpu", 20.0});
    engine.addEvent({now + seconds(2), "cpu", 30.0});

    REQUIRE(engine.stddev() == Catch::Approx(8.165).epsilon(0.01));
}

TEST_CASE("StatisticsEngine z-score identifies outliers", "[statistics]") {
    StatisticsEngine engine(seconds(60));
    auto now = steady_clock::now();

    // Build baseline: 20 events around 50.0
    for (int i = 0; i < 20; ++i) {
        engine.addEvent({now + seconds(i), "cpu", 50.0 + (i % 3)});
    }

    // A value of 100 should have a high z-score
    double z = engine.zScore(100.0);
    REQUIRE(z > 2.0);
}

// --- AnomalyDetector Tests ---

TEST_CASE("AnomalyDetector does not alert on stable data", "[anomaly]") {
    AnomalyDetector detector(seconds(60), 2.0, 3.0);
    auto now = steady_clock::now();

    // Feed 50 stable events
    for (int i = 0; i < 50; ++i) {
        MetricEvent event{now + seconds(i), "cpu", 50.0};
        auto alert = detector.ingest(event);
        REQUIRE_FALSE(alert.has_value());
    }
}

TEST_CASE("AnomalyDetector flags spike as anomaly", "[anomaly]") {
    AnomalyDetector detector(seconds(60), 2.0, 3.0);
    auto now = steady_clock::now();

    // Build baseline
    for (int i = 0; i < 20; ++i) {
        detector.ingest({now + seconds(i), "cpu", 50.0});
    }

    // Inject spike
    MetricEvent spike{now + seconds(21), "cpu", 200.0};
    auto alert = detector.ingest(spike);

    REQUIRE(alert.has_value());
    REQUIRE(alert->severity == "critical");
}

Your test suite is green. You now have verified proof that your data structures, statistics calculations, and anomaly detection logic all work correctly. Next up, the Secret Mission takes this single-threaded engine and turns it into a multithreaded pipeline.

Secret mission

Add Multithreaded Producer-Consumer Processing

Your monitor processes events on a single thread. In production systems at Google-scale, metric events arrive from hundreds of services simultaneously. Can you add a thread-safe event queue with a producer thread generating events and a consumer thread running anomaly detection in parallel?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your project files for future reference, clean up build artifacts, or delete everything entirely.

Resources you used:

  • Project directory with all source files (src/, include/, tests/, lib/, build/ folders).
  • Compiled binaries in the build/ directory.

Keep everything running

No action needed. Choose this if you want to keep experimenting with the code, adding new metric types, or extending the anomaly detection logic.

  • Your project files remain on your Desktop, ready to compile and run anytime.
  • Xcode Command Line Tools remain installed on your system. These are useful for any future C++ or general development work on macOS.

Pause - I'll come back to this later

Remove compiled binaries to free up disk space while keeping your source code intact.

  • Clean up your build artifacts by running this command from inside your project folder:
make clean

This removes the build/ directory and all compiled binaries. Your source code in src/, include/, tests/, and lib/ is untouched.

Delete - I don't want to use this again

Remove all project files from your system completely.

  • Delete the entire project directory by running this command (replace the folder name with whatever you named your project):
cd ~/Desktop
rm -rf health-monitor

On Windows?

Use this command instead:

cd ~\Desktop
rmdir /s /q health-monitor

Nice Work!

Nice Work!

You just built a production-grade system health monitoring engine from scratch in C++. This is the same class of tooling that powers real-time diagnostics at scale in production systems.

Here's what you accomplished:

  • Built a custom templated RingBuffer data structure with O(1) push and circular overwrite, complete with iterator support for range-based for loops.
  • Implemented a sliding window statistics engine computing rolling mean, standard deviation, and z-score anomaly detection to flag metric spikes in real time.
  • Benchmarked throughput at 100K+ events/sec using std::chrono, analyzed compiler optimization impact with -O2 flags, and validated correctness with a comprehensive Catch2 test suite.
  • Added multithreaded producer-consumer processing with a thread-safe event queue, graceful shutdown, and stress testing for race conditions.

Ready to quiz yourself?