Files
o11y/tests/fixtures/metrics_generators.py
PandeyandGitHub f21ce5b010 chore: modern fmts and lints for tests/ (#11074)
* chore(frontend): remove stale e2e scaffold

frontend/e2e/ held an unused settings-only test-plan scaffold from Oct 2025.
Active Playwright specs live at tests/e2e/. Drop the directory, the orphan
playwright.config.ts, the @playwright/test dependency, and the tsconfig
references that pinned them.

* chore(e2e): migrate formatter from prettier to oxfmt

Swap tests/e2e/ onto oxfmt — same tool the frontend adopted in #11057. Style
matches frontend/.oxfmtrc.json (tabs, tabWidth:1) so the two TS trees stay
visually consistent. Drops .prettierrc.json and .prettierignore, adds the
fmt/fmt:check yarn scripts, and reformats the existing specs.

* chore(e2e): migrate linter from eslint to oxlint

Drop eslint + @typescript-eslint plugins in favour of oxlint 1.59 + tsgolint
— same toolchain the frontend adopted in #10176. The .oxlintrc.json mirrors
frontend/.oxlintrc.json with plugins scoped to a Playwright TS codebase
(eslint, typescript, unicorn, import, promise).

Divergence: eslint-plugin-playwright is not ported. Its rules depend on
ESLint APIs (context.getAncestors) that oxlint's JS plugin shim does not
implement, so the five playwright/* rules are dropped in this migration.

* ci(e2e): add fmtlint job

Mirror integrationci.yaml's fmtlint job for e2e. Runs oxfmt --check and
oxlint on tests/e2e/ under the same safe-to-e2e label gating as the
existing test job.

* chore(integration): migrate python tooling from black/pylint/isort/autoflake to ruff

Replace the four-tool stack with ruff — same motivation as the oxfmt/oxlint
swap on the TS side. One tool covers formatting (ruff format), import
sorting (I), unused-import/variable cleanup (F401/F841), and the pylint
rules we actually care about (E/W/F/UP/B/PL).

Rule set mirrors the intent of the prior pylint config: too-many-* checks
and magic-value-comparison stay disabled, dangerous-default-value (now
B006) stays muted. A handful of newly-surfaced codes (B011/B024/B905/E741/
UP047/PLC0206/PLW2901) are also muted to keep this a pure tool swap — each
deserves its own review before enabling.

Divergence: ruff caps line-length at 320, so the prior pylint value of 400
drops to 320. Nothing in tree exceeds 320, so no lines wrap.

No changes to integrationci.yaml — both fmt/lint steps still call
make py-fmt / make py-lint, which now dispatch to ruff.

* chore(e2e): restore playwright lint rules via oxlint jsPlugin

[email protected] was rewritten against ESLint 8's
context.sourceCode.getAncestors() API, which oxlint's JS plugin shim
exposes. The 0.16.x version previously ruled out by context.getAncestors()
missing is no longer a blocker. Bump to 2.10.2, re-add it as a jsPlugin,
and restore the five rules dropped in the initial oxlint migration:
expect-expect, no-conditional-in-test, no-page-pause, no-wait-for-timeout,
prefer-web-first-assertions.

Rule count: 104 → 109.

* chore(frontend): remove stale e2e prompt

frontend/prompts/generate-e2e-test.md is leftover from the same Oct 2025
scaffold removed in ebf735dcc. It references frontend/e2e/utils/login.util.ts,
which no longer exists, and is not wired into anything.

* chore(e2e): make .env.local write layout explicit

The single f-string with inline \n escapes read as a wall of text after
ruff's line-length allowance collapsed it onto one line. Switch to a
triple-quoted f-string so the generated .env.local structure is visible
in source. Byte-for-byte identical output.

* chore(e2e): write .env.local one key per line

Open the file with a context manager and emit each key with its own
f.write call. Same output as before, but each key-value pair is a
discrete statement.
2026-04-23 12:01:42 +00: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 = [
"/api/v1/users",
"/api/v1/orders",
"/api/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 = [
"/api/v1/users",
"/api/v1/orders",
"/api/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