Validation + callback api
This commit is contained in:
166
src/metric.cpp
166
src/metric.cpp
@@ -1,4 +1,5 @@
|
||||
#include "metric.hpp"
|
||||
#include <simdutf.h>
|
||||
|
||||
// WeaselDB Metrics System Design:
|
||||
//
|
||||
@@ -21,7 +22,10 @@
|
||||
#include <atomic>
|
||||
#include <bit>
|
||||
#include <cassert>
|
||||
#include <cctype>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <limits>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
@@ -33,12 +37,30 @@
|
||||
|
||||
namespace metric {
|
||||
|
||||
// Validation helper that works in both debug and release builds
|
||||
static void validate_or_abort(bool condition, const char *message,
|
||||
const char *value) {
|
||||
if (!condition) {
|
||||
std::fprintf(stderr, "WeaselDB metric validation failed: %s: '%s'\n",
|
||||
message, value);
|
||||
std::abort();
|
||||
}
|
||||
}
|
||||
|
||||
// Labels key for second level of map
|
||||
struct LabelsKey {
|
||||
std::vector<std::pair<std::string, std::string>> labels;
|
||||
|
||||
LabelsKey(std::vector<std::pair<std::string, std::string>> l)
|
||||
: labels(std::move(l)) {
|
||||
// Validate all label keys and values
|
||||
for (const auto &[key, value] : labels) {
|
||||
validate_or_abort(is_valid_label_key(key), "invalid label key",
|
||||
key.c_str());
|
||||
validate_or_abort(is_valid_label_value(value), "invalid label value",
|
||||
value.c_str());
|
||||
}
|
||||
|
||||
// Sort labels by key for Prometheus compatibility
|
||||
std::sort(labels.begin(), labels.end(),
|
||||
[](const auto &a, const auto &b) { return a.first < b.first; });
|
||||
@@ -85,12 +107,18 @@ template <> struct Family<Counter>::State {
|
||||
std::unordered_map<LabelsKey, std::unique_ptr<Counter::State>> instances;
|
||||
};
|
||||
std::unordered_map<std::thread::id, PerThreadState> perThreadState;
|
||||
|
||||
// Callback-based metrics (global, not per-thread)
|
||||
std::unordered_map<LabelsKey, MetricCallback<Counter>> callbacks;
|
||||
};
|
||||
|
||||
template <> struct Family<Gauge>::State {
|
||||
std::string name;
|
||||
std::string help;
|
||||
std::unordered_map<LabelsKey, std::unique_ptr<Gauge::State>> instances;
|
||||
|
||||
// Callback-based metrics
|
||||
std::unordered_map<LabelsKey, MetricCallback<Gauge>> callbacks;
|
||||
};
|
||||
|
||||
template <> struct Family<Histogram>::State {
|
||||
@@ -102,6 +130,8 @@ template <> struct Family<Histogram>::State {
|
||||
std::unordered_map<LabelsKey, std::unique_ptr<Histogram::State>> instances;
|
||||
};
|
||||
std::unordered_map<std::thread::id, PerThreadState> perThreadState;
|
||||
|
||||
// Note: No callbacks map - histograms don't support callback-based metrics
|
||||
};
|
||||
|
||||
// Counter: Thread-local, monotonically increasing, single writer per thread
|
||||
@@ -110,10 +140,9 @@ struct Counter::State {
|
||||
friend struct Metric;
|
||||
};
|
||||
|
||||
// Gauge: Global, can increase/decrease, multiple writers (requires mutex)
|
||||
// Gauge: Global, can increase/decrease, multiple writers (uses atomic CAS)
|
||||
struct Gauge::State {
|
||||
std::mutex mutex;
|
||||
double value; // Plain double, protected by mutex
|
||||
AtomicWord value; // Stores double as uint64_t bits, lock-free
|
||||
friend struct Metric;
|
||||
};
|
||||
|
||||
@@ -172,6 +201,13 @@ struct Metric {
|
||||
const std::vector<std::pair<std::string, std::string>> &labels) {
|
||||
std::unique_lock<std::mutex> _{mutex};
|
||||
LabelsKey key{labels};
|
||||
|
||||
// Validate that labels aren't already registered as callback
|
||||
validate_or_abort(
|
||||
family->p->callbacks.find(key) == family->p->callbacks.end(),
|
||||
"labels already registered as callback",
|
||||
key.labels.empty() ? "(no labels)" : key.labels[0].first.c_str());
|
||||
|
||||
auto &ptr =
|
||||
family->p->perThreadState[std::this_thread::get_id()].instances[key];
|
||||
if (!ptr) {
|
||||
@@ -188,10 +224,17 @@ struct Metric {
|
||||
const std::vector<std::pair<std::string, std::string>> &labels) {
|
||||
std::unique_lock<std::mutex> _{mutex};
|
||||
LabelsKey key{labels};
|
||||
|
||||
// Validate that labels aren't already registered as callback
|
||||
validate_or_abort(
|
||||
family->p->callbacks.find(key) == family->p->callbacks.end(),
|
||||
"labels already registered as callback",
|
||||
key.labels.empty() ? "(no labels)" : key.labels[0].first.c_str());
|
||||
|
||||
auto &ptr = family->p->instances[key];
|
||||
if (!ptr) {
|
||||
ptr = std::make_unique<Gauge::State>();
|
||||
ptr->value = 0.0;
|
||||
ptr->value.store(0, std::memory_order_relaxed);
|
||||
}
|
||||
Gauge result;
|
||||
result.p = ptr.get();
|
||||
@@ -228,28 +271,47 @@ struct Metric {
|
||||
};
|
||||
|
||||
void Counter::inc(double x) {
|
||||
assert(x >= 0);
|
||||
validate_or_abort(x >= 0, "counter increment must be >= 0",
|
||||
std::to_string(x).c_str());
|
||||
|
||||
// DESIGN: Single writer per thread allows simple load-modify-store
|
||||
// No CAS loop needed since only one thread writes to this counter
|
||||
auto current_value =
|
||||
std::bit_cast<double>(p->value.load(std::memory_order_relaxed));
|
||||
p->value.store(std::bit_cast<uint64_t>(current_value + x),
|
||||
std::memory_order_relaxed);
|
||||
auto new_value = current_value + x;
|
||||
|
||||
// Validate monotonic property (counter never decreases)
|
||||
validate_or_abort(new_value >= current_value,
|
||||
"counter value overflow/wraparound detected",
|
||||
std::to_string(new_value).c_str());
|
||||
|
||||
p->value.store(std::bit_cast<uint64_t>(new_value), std::memory_order_relaxed);
|
||||
}
|
||||
void Gauge::inc(double x) {
|
||||
// IMPLEMENTATION DETAIL: Mutex protection used internally for thread safety,
|
||||
// but API contract remains single-writer per instance
|
||||
std::unique_lock<std::mutex> _{p->mutex};
|
||||
p->value += x;
|
||||
// Lock-free increment using CAS loop
|
||||
uint64_t expected = p->value.load(std::memory_order_relaxed);
|
||||
uint64_t desired;
|
||||
do {
|
||||
double current_value = std::bit_cast<double>(expected);
|
||||
double new_value = current_value + x;
|
||||
desired = std::bit_cast<uint64_t>(new_value);
|
||||
} while (!p->value.compare_exchange_weak(expected, desired,
|
||||
std::memory_order_relaxed));
|
||||
}
|
||||
void Gauge::dec(double x) {
|
||||
std::unique_lock<std::mutex> _{p->mutex};
|
||||
p->value -= x;
|
||||
// Lock-free decrement using CAS loop
|
||||
uint64_t expected = p->value.load(std::memory_order_relaxed);
|
||||
uint64_t desired;
|
||||
do {
|
||||
double current_value = std::bit_cast<double>(expected);
|
||||
double new_value = current_value - x;
|
||||
desired = std::bit_cast<uint64_t>(new_value);
|
||||
} while (!p->value.compare_exchange_weak(expected, desired,
|
||||
std::memory_order_relaxed));
|
||||
}
|
||||
void Gauge::set(double x) {
|
||||
std::unique_lock<std::mutex> _{p->mutex};
|
||||
p->value = x;
|
||||
// Simple atomic store for set operation
|
||||
p->value.store(std::bit_cast<uint64_t>(x), std::memory_order_relaxed);
|
||||
}
|
||||
void Histogram::observe(double x) {
|
||||
assert(p->thresholds.size() == p->counts.size());
|
||||
@@ -289,6 +351,9 @@ Histogram Family<Histogram>::create(
|
||||
}
|
||||
|
||||
Family<Counter> create_counter(std::string name, std::string help) {
|
||||
validate_or_abort(is_valid_metric_name(name), "invalid counter name",
|
||||
name.c_str());
|
||||
|
||||
std::unique_lock<std::mutex> _{Metric::mutex};
|
||||
auto &familyPtr = Metric::counterFamilies[name];
|
||||
if (!familyPtr) {
|
||||
@@ -302,6 +367,9 @@ Family<Counter> create_counter(std::string name, std::string help) {
|
||||
}
|
||||
|
||||
Family<Gauge> create_gauge(std::string name, std::string help) {
|
||||
validate_or_abort(is_valid_metric_name(name), "invalid gauge name",
|
||||
name.c_str());
|
||||
|
||||
std::unique_lock<std::mutex> _{Metric::mutex};
|
||||
auto &familyPtr = Metric::gaugeFamilies[name];
|
||||
if (!familyPtr) {
|
||||
@@ -316,6 +384,9 @@ Family<Gauge> create_gauge(std::string name, std::string help) {
|
||||
|
||||
Family<Histogram> create_histogram(std::string name, std::string help,
|
||||
std::initializer_list<double> buckets) {
|
||||
validate_or_abort(is_valid_metric_name(name), "invalid histogram name",
|
||||
name.c_str());
|
||||
|
||||
std::unique_lock<std::mutex> _{Metric::mutex};
|
||||
auto &familyPtr = Metric::histogramFamilies[name];
|
||||
if (!familyPtr) {
|
||||
@@ -340,10 +411,69 @@ Family<Histogram> create_histogram(std::string name, std::string help,
|
||||
return family;
|
||||
}
|
||||
|
||||
std::span<std::string> render(ArenaAllocator &arena) {
|
||||
// Prometheus validation functions
|
||||
// Metric names must match [a-zA-Z_:][a-zA-Z0-9_:]*
|
||||
bool is_valid_metric_name(const std::string &name) {
|
||||
if (name.empty())
|
||||
return false;
|
||||
|
||||
// First character must be letter, underscore, or colon
|
||||
char first = name[0];
|
||||
if (!std::isalpha(first) && first != '_' && first != ':') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Remaining characters must be alphanumeric, underscore, or colon
|
||||
for (size_t i = 1; i < name.size(); ++i) {
|
||||
char c = name[i];
|
||||
if (!std::isalnum(c) && c != '_' && c != ':') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Label keys must match [a-zA-Z_][a-zA-Z0-9_]*
|
||||
bool is_valid_label_key(const std::string &key) {
|
||||
if (key.empty())
|
||||
return false;
|
||||
|
||||
// First character must be letter or underscore
|
||||
char first = key[0];
|
||||
if (!std::isalpha(first) && first != '_') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Remaining characters must be alphanumeric or underscore
|
||||
for (size_t i = 1; i < key.size(); ++i) {
|
||||
char c = key[i];
|
||||
if (!std::isalnum(c) && c != '_') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Label keys starting with __ are reserved for internal use
|
||||
if (key.size() >= 2 && key[0] == '_' && key[1] == '_') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Label values can contain any UTF-8 characters (no specific restrictions)
|
||||
bool is_valid_label_value(const std::string &value) {
|
||||
// Prometheus allows any UTF-8 string as label value
|
||||
// Validate UTF-8 encoding for correctness using simdutf
|
||||
return simdutf::validate_utf8(value.c_str(), value.size());
|
||||
}
|
||||
|
||||
std::span<std::string_view> render(ArenaAllocator &arena) {
|
||||
// TODO: Implement Prometheus text format rendering
|
||||
static std::string empty_result = "";
|
||||
return std::span<std::string>(&empty_result, 0);
|
||||
// All string data should be allocated in the arena and returned as
|
||||
// string_views
|
||||
static std::string_view empty_result = "";
|
||||
return std::span<std::string_view>(&empty_result, 0);
|
||||
}
|
||||
|
||||
// Static member definitions
|
||||
|
||||
@@ -8,12 +8,23 @@
|
||||
// - Single-writer semantics: Each metric instance bound to creating thread
|
||||
// - Lock-free operations using atomic<uint64_t> storage for doubles
|
||||
// - Full IEEE 754 double precision preservation via bit reinterpretation
|
||||
// - Single global registry: All metrics registered in one global namespace
|
||||
//
|
||||
// CRITICAL THREAD SAFETY CONSTRAINT:
|
||||
// Each metric instance has exactly ONE writer thread (the creating thread).
|
||||
// It is undefined behavior to call inc()/dec()/set()/observe() from a different
|
||||
// thread.
|
||||
//
|
||||
// REGISTRY MODEL:
|
||||
// This implementation uses a single global registry for all metrics, unlike
|
||||
// typical Prometheus client libraries that support multiple registries.
|
||||
// This design choice prioritizes simplicity and performance over flexibility.
|
||||
//
|
||||
// METRIC LIFECYCLE:
|
||||
// Metrics are created once and persist for the application lifetime. There is
|
||||
// no unregistration mechanism - this prevents accidental metric loss and
|
||||
// simplifies the implementation.
|
||||
//
|
||||
// USAGE:
|
||||
// auto counter_family = metric::create_counter("requests_total", "Total
|
||||
// requests"); auto counter = counter_family.create({{"method", "GET"}}); //
|
||||
@@ -100,6 +111,8 @@ template <class T> struct Family {
|
||||
|
||||
// Create metric instance with specific labels
|
||||
// Labels are sorted by key for Prometheus compatibility
|
||||
// ERROR: Will abort if labels already registered via register_callback()
|
||||
// OK: Multiple calls with same labels return same instance (idempotent)
|
||||
T create(std::vector<std::pair<std::string, std::string>> labels);
|
||||
|
||||
private:
|
||||
@@ -129,6 +142,42 @@ Family<Histogram> create_histogram(std::string name, std::string help,
|
||||
std::initializer_list<double> buckets);
|
||||
|
||||
// Render all metrics in Prometheus text format
|
||||
std::span<std::string> render(ArenaAllocator &arena);
|
||||
// TODO: Implement Prometheus text exposition format
|
||||
// THREAD SAFETY: Serialized by global mutex - callbacks need not be thread-safe
|
||||
std::span<std::string_view> render(ArenaAllocator &arena);
|
||||
|
||||
// Validation functions for Prometheus compatibility
|
||||
bool is_valid_metric_name(const std::string &name);
|
||||
bool is_valid_label_key(const std::string &key);
|
||||
bool is_valid_label_value(const std::string &value);
|
||||
|
||||
// Callback function type for dynamic metric values
|
||||
// Called during render() to get current metric value
|
||||
// THREAD SAFETY: May be called from arbitrary thread, but serialized by
|
||||
// render() mutex
|
||||
// - no need to be thread-safe internally
|
||||
template <typename T> using MetricCallback = std::function<double()>;
|
||||
|
||||
// Register callback-based metric to Family
|
||||
// Validates that label set isn't already taken by either:
|
||||
// - A previous register_callback() call (callbacks must be unique)
|
||||
// - A create() call (static and callback metrics cannot coexist for same
|
||||
// labels)
|
||||
//
|
||||
// Similarly, create() will validate that label set isn't already registered as
|
||||
// callback Note: create() can be called multiple times with same labels
|
||||
// (returns same instance)
|
||||
template <>
|
||||
void Family<Counter>::register_callback(
|
||||
std::vector<std::pair<std::string, std::string>> labels,
|
||||
MetricCallback<Counter> callback);
|
||||
|
||||
template <>
|
||||
void Family<Gauge>::register_callback(
|
||||
std::vector<std::pair<std::string, std::string>> labels,
|
||||
MetricCallback<Gauge> callback);
|
||||
|
||||
// Note: Histograms do not support callbacks due to their multi-value nature
|
||||
// (buckets + sum + count). Use static histogram metrics only.
|
||||
|
||||
} // namespace metric
|
||||
|
||||
Reference in New Issue
Block a user