Files
o11y/tests/fixtures/metrics_generators.py
T
zeekay 46c6241c52 Spell every o11y caller /v1/o11y
The server moved off /api/vN; the integration suite, handler tests,
install.sh's health wait and the docs still called the old tree.

Claude-Session: https://claude.ai/code/session_01TUxvfuta1Z44KVNYuAz71F
2026-09-24 15:05:09 -07:00

602 lines
20 KiB
Python

import math
import random
from collections.abc import Generator
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from fixtures.metrics import Metrics
@dataclass
class DeterministicAnomalies:
# resets: (offset)
COUNTER_RESET_OFFSETS: list[timedelta] = field(
default_factory=lambda: [
timedelta(hours=6),
timedelta(hours=24),
]
)
# gaps: (offset, duration)
DATA_GAP_OFFSETS: list[tuple[timedelta, timedelta]] = field(
default_factory=lambda: [
(timedelta(hours=12), timedelta(minutes=30)), # 30 min gap
(timedelta(hours=36), timedelta(minutes=15)), # 15 min gap
]
)
# spikes: (offset, multiplier)
SPIKE_OFFSETS: list[tuple[timedelta, float]] = field(
default_factory=lambda: [
(timedelta(hours=18), 10.0), # at this point the value spikes 10x
]
)
# 0 value offsets
ZERO_VALUE_OFFSETS: list[timedelta] = field(
default_factory=lambda: [
timedelta(hours=30), # at this point the value drops to zero
]
)
def is_in_gap(self, offset: timedelta) -> bool:
for gap_start, gap_duration in self.DATA_GAP_OFFSETS:
if gap_start <= offset < gap_start + gap_duration:
return True
return False
def get_spike_multiplier(self, offset: timedelta) -> float:
for spike_offset, multiplier in self.SPIKE_OFFSETS:
# lasts for 1 minute, make it configurable?
if spike_offset <= offset < spike_offset + timedelta(minutes=1):
return multiplier
return 1.0
def should_reset_counter(self, offset: timedelta) -> bool:
for reset_offset in self.COUNTER_RESET_OFFSETS:
if reset_offset <= offset < reset_offset + timedelta(minutes=1):
return True
return False
def should_be_zero(self, offset: timedelta) -> bool:
for zero_offset in self.ZERO_VALUE_OFFSETS:
# window of 5 minutes
if zero_offset <= offset < zero_offset + timedelta(minutes=5):
return True
return False
class MetricsDataGenerator:
def __init__(
self,
start_time: datetime,
end_time: datetime,
step_seconds: int = 60,
seed: int = 42,
):
self.start = start_time
self.end = end_time
self.step = step_seconds
self.anomalies = DeterministicAnomalies()
self.rng = random.Random(seed)
duration = (self.end - self.start).total_seconds()
self.num_points = int(duration / step_seconds)
def _timestamps(self) -> Generator[datetime]:
current = self.start
while current < self.end:
yield current
current += timedelta(seconds=self.step)
def _offset_from_start(self, ts: datetime) -> timedelta:
return ts - self.start
def steady_with_noise(self, base: float, noise_pct: float = 0.1) -> Generator[float]:
for _ in range(self.num_points):
noise = self.rng.uniform(-noise_pct, noise_pct) * base
yield base + noise
def diurnal_pattern(self, min_val: float, max_val: float) -> Generator[float]:
amplitude = (max_val - min_val) / 2
baseline = min_val + amplitude
for i, ts in enumerate[datetime](self._timestamps()):
# peak at 14:00
hour = ts.hour + ts.minute / 60.0
# shift so peak is at 14:00
phase = (hour - 14) * (2 * math.pi / 24)
value = baseline + amplitude * math.cos(phase)
# add some noise
noise = self.rng.uniform(-0.05, 0.05) * value
yield max(0, value + noise)
if i >= self.num_points - 1:
break
def monotonic_increasing(self, rate_per_minute: float, noise_pct: float = 0.1) -> Generator[float]:
value = 0.0
for ts in self._timestamps():
offset = self._offset_from_start(ts)
# reset now?
if self.anomalies.should_reset_counter(offset):
value = 0.0
increment = rate_per_minute * (self.step / 60)
noise = self.rng.uniform(1 - noise_pct, 1 + noise_pct)
value += increment * noise
yield value
def spike_pattern(
self,
base: float,
spike_factor: float = 10.0,
spike_interval_minutes: int = 60,
) -> Generator[float]:
for i, ts in enumerate(self._timestamps()):
offset = self._offset_from_start(ts)
# deterministic spike
multiplier = self.anomalies.get_spike_multiplier(offset)
if multiplier > 1.0:
value = base * multiplier
# periodic spike
elif i > 0 and i % spike_interval_minutes == 0:
value = base * spike_factor
else:
noise = self.rng.uniform(-0.1, 0.1) * base
value = base + noise
yield value
def gauge_with_zero_drops(
self,
base: float,
noise_pct: float = 0.1,
) -> Generator[float]:
for ts in self._timestamps():
offset = self._offset_from_start(ts)
if self.anomalies.should_be_zero(offset):
yield 0.0
else:
noise = self.rng.uniform(-noise_pct, noise_pct) * base
yield max(0, base + noise)
def latency_distribution(self, p50_ms: float, p99_ms: float) -> Generator[list[float]]:
# see otel defaults (in ms)
# https://opentelemetry.io/docs/specs/otel/metrics/sdk/#explicit-bucket-histogram-aggregation
buckets = [5, 10, 25, 50, 75, 100, 250, 500, 750, 1000, 2500, 5000, 7500, 10000]
for _ in range(self.num_points):
# log-normal distribution
sigma = math.log(p99_ms / p50_ms) / 2.33 # z-score for 99th percentile
mu = math.log(p50_ms)
# observations for each bucket
counts = []
total_requests = self.rng.randint(100, 1000)
for i, bucket in enumerate(buckets):
# CDF
if bucket <= 0:
prob = 0
else:
z = (math.log(bucket) - mu) / sigma
prob = 0.5 * (1 + math.erf(z / math.sqrt(2)))
if i == 0:
count = int(prob * total_requests)
else:
prev_bucket = buckets[i - 1]
z_prev = (math.log(prev_bucket) - mu) / sigma
prob_prev = 0.5 * (1 + math.erf(z_prev / math.sqrt(2)))
count = int((prob - prob_prev) * total_requests)
counts.append(max(0, count))
yield counts
def generate_gauge_metrics(
self,
metric_name: str = "system.cpu.utilization",
services: list[str] = None,
hosts: list[str] = None,
) -> list[Metrics]:
if services is None:
services = ["frontend", "backend", "worker", "database"]
if hosts is None:
hosts = ["host-1", "host-2", "host-3", "host-4", "host-5"]
metrics = []
for service in services:
for host in hosts:
# diurnal pattern for CPU utilization (as a pct, we could have done state based counter metric as well)
values = list[float](self.diurnal_pattern(20.0, 80.0))
labels = {"service_name": service, "host": host}
for i, ts in enumerate[datetime](self._timestamps()):
offset = self._offset_from_start(ts)
# inject gaps
if self.anomalies.is_in_gap(offset):
continue
if i < len(values):
metrics.append(
Metrics(
metric_name=metric_name,
labels=labels,
timestamp=ts,
value=values[i],
temporality="Unspecified",
type_="Gauge",
is_monotonic=False,
)
)
return metrics
def generate_cumulative_counter_metrics(
self,
metric_name: str = "http.server.request.count",
services: list[str] = None,
endpoints: list[str] = None,
status_codes: list[str] = None,
) -> list[Metrics]:
if services is None:
services = ["api", "web", "auth"]
if endpoints is None:
endpoints = [
"/v1/users",
"/v1/orders",
"/v1/products",
"/health",
"/metrics",
]
if status_codes is None:
status_codes = ["200", "500"]
metrics = []
for service in services:
for endpoint in endpoints:
for status_code in status_codes:
# rate varies by status code
base_rate = 100.0 if status_code == "200" else 5.0
values = list(self.monotonic_increasing(base_rate, 0.1))
labels = {
"service_name": service,
"endpoint": endpoint,
"status_code": status_code,
}
for i, ts in enumerate[datetime](self._timestamps()):
offset = self._offset_from_start(ts)
# inject artificial gaps
if self.anomalies.is_in_gap(offset):
continue
if i < len(values):
metrics.append(
Metrics(
metric_name=metric_name,
labels=labels,
timestamp=ts,
value=values[i],
temporality="Cumulative",
type_="Sum",
is_monotonic=True,
)
)
return metrics
def generate_delta_counter_metrics(
self,
metric_name: str = "http.server.request.delta",
services: list[str] = None,
endpoints: list[str] = None,
status_codes: list[str] = None,
) -> list[Metrics]:
if services is None:
services = ["api", "web", "auth"]
if endpoints is None:
endpoints = [
"/v1/users",
"/v1/orders",
"/v1/products",
"/health",
"/metrics",
]
if status_codes is None:
status_codes = ["200", "500"]
metrics = []
for service in services:
for endpoint in endpoints:
for status_code in status_codes:
base_count = 100.0 if status_code == "200" else 5.0
labels = {
"service_name": service,
"endpoint": endpoint,
"status_code": status_code,
}
for ts in self._timestamps():
offset = self._offset_from_start(ts)
if self.anomalies.is_in_gap(offset):
continue
multiplier = self.anomalies.get_spike_multiplier(offset)
noise = self.rng.uniform(0.8, 1.2)
value = base_count * multiplier * noise
metrics.append(
Metrics(
metric_name=metric_name,
labels=labels,
timestamp=ts,
value=value,
temporality="Delta",
type_="Sum",
is_monotonic=True,
)
)
return metrics
def generate_connection_gauge_metrics(
self,
metric_name: str = "db.client.connections",
services: list[str] = None,
pool_names: list[str] = None,
) -> list[Metrics]:
if services is None:
services = ["backend", "worker"]
if pool_names is None:
pool_names = ["primary", "replica"]
metrics = []
for service in services:
for pool in pool_names:
base = 20.0 if pool == "primary" else 10.0
values = list[float](self.gauge_with_zero_drops(base, 0.2))
labels = {"service_name": service, "pool": pool}
for i, ts in enumerate[datetime](self._timestamps()):
offset = self._offset_from_start(ts)
if self.anomalies.is_in_gap(offset):
continue
if i < len(values):
metrics.append(
Metrics(
metric_name=metric_name,
labels=labels,
timestamp=ts,
value=values[i],
temporality="Unspecified",
type_="Gauge",
is_monotonic=False,
)
)
return metrics
def generate_gc_duration_metrics(
self,
metric_name: str = "process.runtime.gc.duration",
services: list[str] = None,
) -> list[Metrics]:
if services is None:
services = ["frontend", "backend", "worker", "api"]
metrics = []
for service in services:
values = list[float](self.spike_pattern(5.0, 10.0, 30)) # bump every 30 minutes (to similate real pattern)
labels = {"service_name": service}
for i, ts in enumerate[datetime](self._timestamps()):
offset = self._offset_from_start(ts)
if self.anomalies.is_in_gap(offset):
continue
if i < len(values):
metrics.append(
Metrics(
metric_name=metric_name,
labels=labels,
timestamp=ts,
value=values[i],
temporality="Unspecified",
type_="Gauge",
is_monotonic=False,
)
)
return metrics
def generate_all_metrics(self) -> dict[str, list[Metrics]]:
return {
"gauge": self.generate_gauge_metrics(),
"cumulative_counter": self.generate_cumulative_counter_metrics(),
"delta_counter": self.generate_delta_counter_metrics(),
"connection_gauge": self.generate_connection_gauge_metrics(),
"gc_duration": self.generate_gc_duration_metrics(),
}
def generate_flat_metrics(self) -> list[Metrics]:
all_metrics = []
for metrics_list in self.generate_all_metrics().values():
all_metrics.extend(metrics_list)
return all_metrics
def create_test_data_generator(
duration_hours: int = 48,
step_seconds: int = 60,
seed: int = 42,
) -> MetricsDataGenerator:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
start = now - timedelta(hours=duration_hours)
return MetricsDataGenerator(
start_time=start,
end_time=now,
step_seconds=step_seconds,
seed=seed,
)
def generate_simple_gauge_series(
metric_name: str,
labels: dict[str, str],
values: list[float],
start_time: datetime | None = None,
step_seconds: int = 60,
) -> list[Metrics]:
if start_time is None:
start_time = datetime.now(tz=UTC).replace(second=0, microsecond=0)
metrics = []
for i, value in enumerate(values):
ts = start_time + timedelta(seconds=i * step_seconds)
metrics.append(
Metrics(
metric_name=metric_name,
labels=labels,
timestamp=ts,
value=value,
temporality="Unspecified",
type_="Gauge",
is_monotonic=False,
)
)
return metrics
def generate_simple_counter_series(
metric_name: str,
labels: dict[str, str],
values: list[float],
temporality: str = "Cumulative",
start_time: datetime | None = None,
step_seconds: int = 60,
) -> list[Metrics]:
if start_time is None:
start_time = datetime.now(tz=UTC).replace(second=0, microsecond=0)
metrics = []
for i, value in enumerate(values):
ts = start_time + timedelta(seconds=i * step_seconds)
metrics.append(
Metrics(
metric_name=metric_name,
labels=labels,
timestamp=ts,
value=value,
temporality=temporality,
type_="Sum",
is_monotonic=True,
)
)
return metrics
def generate_non_monotonic_sum_series(
metric_name: str,
labels: dict[str, str],
values: list[float],
temporality: str = "Cumulative",
start_time: datetime | None = None,
step_seconds: int = 60,
) -> list[Metrics]:
if start_time is None:
start_time = datetime.now(tz=UTC).replace(second=0, microsecond=0)
metrics = []
for i, value in enumerate(values):
ts = start_time + timedelta(seconds=i * step_seconds)
metrics.append(
Metrics(
metric_name=metric_name,
labels=labels,
timestamp=ts,
value=value,
temporality=temporality,
type_="Sum",
is_monotonic=False, # non-monotonic sums are gauges for us
)
)
return metrics
def generate_counter_with_resets(
metric_name: str,
labels: dict[str, str],
num_points: int,
rate_per_point: float,
reset_at_indices: list[int],
start_time: datetime | None = None,
step_seconds: int = 60,
) -> list[Metrics]:
if start_time is None:
start_time = datetime.now(tz=UTC).replace(second=0, microsecond=0)
metrics = []
value = 0.0
for i in range(num_points):
if i in reset_at_indices:
value = 0.0
else:
value += rate_per_point
ts = start_time + timedelta(seconds=i * step_seconds)
metrics.append(
Metrics(
metric_name=metric_name,
labels=labels,
timestamp=ts,
value=value,
temporality="Cumulative",
type_="Sum",
is_monotonic=True,
)
)
return metrics
def generate_sparse_series(
metric_name: str,
labels: dict[str, str],
values_at_indices: dict[int, float],
total_points: int,
temporality: str = "Unspecified",
type_: str = "Gauge",
start_time: datetime | None = None,
step_seconds: int = 60,
) -> list[Metrics]:
if start_time is None:
start_time = datetime.now(tz=UTC).replace(second=0, microsecond=0)
metrics = []
for i, value in values_at_indices.items():
if i < total_points:
ts = start_time + timedelta(seconds=i * step_seconds)
metrics.append(
Metrics(
metric_name=metric_name,
labels=labels,
timestamp=ts,
value=value,
temporality=temporality,
type_=type_,
is_monotonic=type_ == "Sum",
)
)
return metrics