Files
o11y/tests/fixtures/seed_golden_dataset.py
zeekayandHanzo Dev 43087df25c docs: name the OLAP layer Hanzo Datastore, and correct stale identifiers
The Go plane is already debranded — telemetrystore.Config.Datastore with the
mapstructure:"datastore" tag, MustNewName("datastore"), the accessor
TelemetryStore.Datastore(), and the hanzo-ds/* driver and mock modules. The
prose had not followed, and several statements had gone stale enough to be
wrong:

- LLM.md claimed the provider selector "stays clickhouse" and that
  MustNewName("clickhouse") was registered; both are datastore now.
- LLM.md referenced telemetrystore.Config.Clickhouse and
  TelemetryStore.ClickhouseDB(), neither of which exists.
- LLM.md's dependency table pinned hanzoai/clickhouse-go-mock v0.14.1; go.mod
  requires github.com/hanzo-ds/mock v0.14.4.
- Test comments referenced the "ClickHouse tab"; the query editor enum is
  EQueryType.DATASTORE = 'datastore_sql'.
- Test comments referenced the o11yclickhousemetrics exporter; the collector
  fork ships o11ydatastoremetrics.

Left alone, with the reasons recorded in LLM.md: the builtin-integration id
"clickhouse", which identifies the third-party deployments users point o11y at
and keys the 088_migrate_ii_dashboards dashboards; CLICKHOUSE_SKIP_USER_SETUP,
which the pinned server image's own entrypoint reads; the upstream column of
the fork-provenance table; and the integration test fixtures, which run against
the upstream clickhouse/clickhouse-server testcontainer.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-26 19:01:39 -07:00

362 lines
13 KiB
Python

"""Golden dataset fixture — seeds OTel-demo-shaped metrics, traces, and
logs into the datastore via the seeder on every test_setup invocation.
Timestamps are rebased to `now` so panels with default time windows
always find data. To refresh the dataset shape on disk, run
`uv run python -m fixtures.seed_golden_dataset regenerate`.
"""
from __future__ import annotations
import datetime
import json
import logging
import os
import random
from collections.abc import Iterator
from pathlib import Path
import pytest
import requests
from fixtures import types
logger = logging.getLogger(__name__)
_GOLDEN_DIR = Path(__file__).resolve().parent.parent / "seeder" / "golden"
METRICS_PATH = _GOLDEN_DIR / "otel-demo-metrics-golden.jsonl"
TRACES_PATH = _GOLDEN_DIR / "otel-demo-traces-golden.jsonl"
LOGS_PATH = _GOLDEN_DIR / "otel-demo-logs-golden.jsonl"
# ─── Generator ───────────────────────────────────────────────────────────
_SERVICES = [
"adservice",
"cartservice",
"checkoutservice",
"currencyservice",
"frontend",
"paymentservice",
"productcatalogservice",
"shippingservice",
]
_OPERATIONS = {
"adservice": ["/ads/get", "/ads/list"],
"cartservice": ["/cart/add", "/cart/get", "/cart/empty"],
"checkoutservice": ["/checkout"],
"currencyservice": ["/currency/convert"],
"frontend": ["/", "/product", "/checkout"],
"paymentservice": ["/payment/charge"],
"productcatalogservice": ["/products/list", "/products/get"],
"shippingservice": ["/shipping/quote", "/shipping/ship"],
}
_DB_SERVICES = {"cartservice", "productcatalogservice"}
_ENV = "production"
_BUCKET_MINUTES = 5
_WINDOW_HOURS = 6
def _generate_metrics() -> list[dict]:
rng = random.Random(20260511)
samples: list[dict] = []
n_buckets = (_WINDOW_HOURS * 60) // _BUCKET_MINUTES
base_counter = 1000
for service in _SERVICES:
for operation in _OPERATIONS[service]:
for status in ("STATUS_CODE_OK", "STATUS_CODE_ERROR"):
weight = 9 if status == "STATUS_CODE_OK" else 1
counter = base_counter
latency_sum = 0
for i in range(n_buckets):
minutes_ago = (_WINDOW_HOURS * 60) - (i + 1) * _BUCKET_MINUTES
bucket_calls = int(weight * (50 + 20 * (1 + i % 12 / 12.0) + rng.randint(0, 10)))
counter += bucket_calls
latency_sum += bucket_calls * rng.randint(100_000, 500_000)
resource_attrs = {
"service.name": service,
"deployment.environment": _ENV,
"k8s.namespace.name": f"o11y-{service}",
}
point_attrs = {
"operation": operation,
"status_code": status,
"span_kind": "SPAN_KIND_SERVER",
}
for name, value in (
("o11y_calls_total", counter),
("o11y_latency_count", counter),
("o11y_latency_sum", latency_sum),
):
samples.append(
{
"metric_name": name,
"minutes_ago": minutes_ago,
"value": value,
"resource_attributes": resource_attrs,
"attributes": point_attrs,
"is_monotonic": True,
}
)
if service in _DB_SERVICES:
db_counter = 0
for i in range(n_buckets):
minutes_ago = (_WINDOW_HOURS * 60) - (i + 1) * _BUCKET_MINUTES
db_counter += 20 + rng.randint(0, 15)
samples.append(
{
"metric_name": "o11y_db_latency_count",
"minutes_ago": minutes_ago,
"value": db_counter,
"resource_attributes": {
"service.name": service,
"deployment.environment": _ENV,
"k8s.namespace.name": f"o11y-{service}",
},
"attributes": {
"db.system": "postgresql" if service == "cartservice" else "mongodb",
},
"is_monotonic": True,
}
)
return samples
def _generate_traces() -> list[dict]:
rng = random.Random(20260512)
samples: list[dict] = []
n_buckets = 12
for service in _SERVICES:
for operation in _OPERATIONS[service]:
for i in range(n_buckets):
minutes_ago = int((_WINDOW_HOURS * 60) - i * (_WINDOW_HOURS * 60 / n_buckets))
http_status = "500" if rng.random() < 0.05 else "200"
samples.append(
{
"name": f"{service} {operation}",
"kind": "SERVER",
"minutes_ago": minutes_ago,
"duration_ms": rng.randint(50, 500),
"status": "ERROR" if http_status == "500" else "OK",
"resource_attributes": {
"service.name": service,
"deployment.environment": _ENV,
"k8s.namespace.name": f"o11y-{service}",
},
"attributes": {
"http.method": "GET" if "get" in operation.lower() or operation == "/" else "POST",
"http.route": operation,
"http.status_code": http_status,
},
}
)
return samples
_LOG_SEVERITIES = [("INFO", 0.85), ("WARN", 0.10), ("ERROR", 0.05)]
_LOG_BODIES = {
"INFO": ["Handled request", "Cache hit", "Connection established"],
"WARN": ["Slow response detected", "Cache miss", "Retrying upstream call"],
"ERROR": ["Upstream call failed", "Database query timed out", "Auth failed"],
}
def _generate_logs() -> list[dict]:
rng = random.Random(20260512)
samples: list[dict] = []
n_buckets = 24
for service in _SERVICES:
for i in range(n_buckets):
minutes_ago = int((_WINDOW_HOURS * 60) - i * (_WINDOW_HOURS * 60 / n_buckets))
r = rng.random()
cumulative = 0.0
severity = "INFO"
for name, weight in _LOG_SEVERITIES:
cumulative += weight
if r < cumulative:
severity = name
break
samples.append(
{
"body": f"[{service}] {rng.choice(_LOG_BODIES[severity])}",
"severity": severity,
"minutes_ago": minutes_ago,
"resource_attributes": {
"service.name": service,
"deployment.environment": _ENV,
"k8s.namespace.name": f"o11y-{service}",
},
"attributes": {"logger.name": f"{service}.app"},
}
)
return samples
def _write_jsonl(path: Path, samples: list[dict]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w") as f:
for s in samples:
f.write(json.dumps(s, separators=(",", ":")))
f.write("\n")
def regenerate() -> dict[str, int]:
metrics = _generate_metrics()
traces = _generate_traces()
logs = _generate_logs()
_write_jsonl(METRICS_PATH, metrics)
_write_jsonl(TRACES_PATH, traces)
_write_jsonl(LOGS_PATH, logs)
return {"metrics": len(metrics), "traces": len(traces), "logs": len(logs)}
# ─── Loader ──────────────────────────────────────────────────────────────
_KIND_TO_INT = {
"UNSPECIFIED": 0,
"INTERNAL": 1,
"SERVER": 2,
"CLIENT": 3,
"PRODUCER": 4,
"CONSUMER": 5,
}
_STATUS_TO_INT = {"UNSET": 0, "OK": 1, "ERROR": 2}
def _read_jsonl(path: Path) -> Iterator[dict]:
with path.open() as f:
for line in f:
line = line.strip()
if line:
yield json.loads(line)
def _iso_minus_minutes(now: datetime.datetime, minutes: float) -> str:
ts = now - datetime.timedelta(minutes=minutes)
return ts.replace(tzinfo=datetime.UTC).isoformat().replace("+00:00", "Z")
def _rebased_metric(sample: dict, now: datetime.datetime) -> dict:
out = {k: v for k, v in sample.items() if k != "minutes_ago"}
out["timestamp"] = _iso_minus_minutes(now, sample["minutes_ago"])
return out
def _rebased_trace(sample: dict, now: datetime.datetime) -> dict:
return {
"timestamp": _iso_minus_minutes(now, sample["minutes_ago"]),
"duration": f"PT{sample['duration_ms'] / 1000:.3f}S",
"trace_id": sample.get("trace_id") or os.urandom(16).hex(),
"span_id": sample.get("span_id") or os.urandom(8).hex(),
"name": sample["name"],
"kind": _KIND_TO_INT.get(str(sample.get("kind", "SERVER")).upper(), 2),
"status_code": _STATUS_TO_INT.get(str(sample.get("status", "UNSET")).upper(), 0),
"resources": sample.get("resource_attributes", {}),
"attributes": sample.get("attributes", {}),
}
def _rebased_log(sample: dict, now: datetime.datetime) -> dict:
return {
"timestamp": _iso_minus_minutes(now, sample["minutes_ago"]),
"body": sample["body"],
"severity_text": str(sample.get("severity", "INFO")).upper(),
"resources": sample.get("resource_attributes", {}),
"attributes": sample.get("attributes", {}),
}
def _post_batches(url: str, rows: Iterator[dict], batch_size: int, timeout: int) -> int:
batch: list[dict] = []
total = 0
for row in rows:
batch.append(row)
if len(batch) >= batch_size:
response = requests.post(url, json=batch, timeout=timeout)
response.raise_for_status()
total += len(batch)
batch = []
if batch:
response = requests.post(url, json=batch, timeout=timeout)
response.raise_for_status()
total += len(batch)
return total
def seed(
seeder_base_url: str,
*,
batch_size: int = 500,
timeout: int = 60,
clear_first: bool = True,
) -> dict[str, int]:
"""Wipe each signal table (via DELETE /telemetry/<signal>) and replay
the golden dataset with timestamps rebased to `now`. Each call leaves
the stack in the exact state the JSONL files describe — chart-data
assertions are reproducible across sessions regardless of how many
earlier sessions seeded."""
for path in (METRICS_PATH, TRACES_PATH, LOGS_PATH):
if not path.exists():
raise FileNotFoundError(f"golden dataset missing at {path} — run `uv run python -m fixtures.seed_golden_dataset regenerate`")
now = datetime.datetime.now(datetime.UTC).replace(microsecond=0, tzinfo=None)
base = seeder_base_url.rstrip("/")
if clear_first:
for signal in ("metrics", "traces", "logs"):
requests.delete(f"{base}/telemetry/{signal}", timeout=timeout).raise_for_status()
counts = {
"metrics": _post_batches(
base + "/telemetry/metrics",
(_rebased_metric(s, now) for s in _read_jsonl(METRICS_PATH)),
batch_size,
timeout,
),
"traces": _post_batches(
base + "/telemetry/traces",
(_rebased_trace(s, now) for s in _read_jsonl(TRACES_PATH)),
batch_size,
timeout,
),
"logs": _post_batches(
base + "/telemetry/logs",
(_rebased_log(s, now) for s in _read_jsonl(LOGS_PATH)),
batch_size,
timeout,
),
}
logger.info("seeded through %s: %s", base, counts)
return counts
# ─── Fixture ─────────────────────────────────────────────────────────────
@pytest.fixture(name="golden_dataset", scope="package")
def golden_dataset(seeder: types.TestContainerDocker) -> dict[str, int]:
"""Seed metrics + traces + logs into the running stack via the
seeder. Runs unconditionally on every test_setup invocation so the
rebased timestamps always anchor against `now`."""
return seed(seeder.host_configs["8080"].base())
if __name__ == "__main__":
import sys
logging.basicConfig(level=logging.INFO)
if len(sys.argv) < 2:
sys.stderr.write("usage: seed_golden_dataset.py seed <seeder-base-url> | regenerate\n")
sys.exit(2)
cmd = sys.argv[1]
if cmd == "regenerate":
print(f"wrote {regenerate()}")
elif cmd == "seed":
if len(sys.argv) != 3:
sys.stderr.write("usage: seed_golden_dataset.py seed <seeder-base-url>\n")
sys.exit(2)
print(f"seeded {seed(sys.argv[2])}")
else:
sys.stderr.write(f"unknown command: {cmd}\n")
sys.exit(2)