Implement client cost attribution

This commit is contained in:
tegwick 2026-08-10 20:32:09 +02:00
parent d2b9bc4b32
commit 33883e0977
15 changed files with 362 additions and 21 deletions

View file

@ -0,0 +1,59 @@
"""Stable external client/application/instance attribution keys."""
from __future__ import annotations
import re
from dataclasses import dataclass
_PART_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
def _validate_part(name: str, value: str) -> str:
normalized = value.strip()
if not _PART_PATTERN.fullmatch(normalized):
raise ValueError(
f"{name} must be 1-128 characters using letters, digits, '.', '_', ':', or '-'"
)
return normalized
@dataclass(frozen=True)
class ClientAttribution:
"""External identity seam; fin-hub does not own these identities."""
client_id: str
application_id: str
app_instance_id: str
def __post_init__(self) -> None:
object.__setattr__(self, "client_id", _validate_part("client_id", self.client_id))
object.__setattr__(
self, "application_id", _validate_part("application_id", self.application_id)
)
object.__setattr__(
self, "app_instance_id", _validate_part("app_instance_id", self.app_instance_id)
)
@property
def key(self) -> str:
return (
f"client:{self.client_id}|app:{self.application_id}|"
f"instance:{self.app_instance_id}"
)
def optional_attribution(
client_id: str | None,
application_id: str | None,
app_instance_id: str | None,
) -> ClientAttribution | None:
"""Return an attribution or reject ambiguous partially attributed input."""
values = (client_id, application_id, app_instance_id)
if all(value is None or not value.strip() for value in values):
return None
if any(value is None or not value.strip() for value in values):
raise ValueError(
"client_id, application_id, and app_instance_id must be supplied together"
)
return ClientAttribution(client_id or "", application_id or "", app_instance_id or "")

View file

@ -87,6 +87,10 @@ def _cmd_ops_costs(args: argparse.Namespace) -> int:
amount=row.amount,
currency=row.currency,
source=row.source,
client_id=row.client_id,
application_id=row.application_id,
app_instance_id=row.app_instance_id,
cost_attribution_key=row.cost_attribution_key,
)
for row in parse_hosteurope_csv(Path(args.path))
]
@ -256,4 +260,4 @@ def main(argv: list[str] | None = None) -> int:
if __name__ == "__main__":
sys.exit(main())
sys.exit(main())

View file

@ -6,6 +6,8 @@ from collections import defaultdict
from dataclasses import dataclass
from typing import Iterable
from fin_hub.attribution import optional_attribution
@dataclass(frozen=True)
class ServiceCostLine:
@ -15,6 +17,19 @@ class ServiceCostLine:
amount: float
currency: str
source: str
client_id: str | None = None
application_id: str | None = None
app_instance_id: str | None = None
cost_attribution_key: str | None = None
def __post_init__(self) -> None:
attribution = optional_attribution(
self.client_id, self.application_id, self.app_instance_id
)
expected_key = attribution.key if attribution else None
if self.cost_attribution_key not in (None, expected_key):
raise ValueError("cost_attribution_key does not match its attribution dimensions")
object.__setattr__(self, "cost_attribution_key", expected_key)
def build_service_cost_report(
@ -22,6 +37,7 @@ def build_service_cost_report(
) -> dict:
by_service: dict[str, dict] = {}
totals_by_month: dict[str, float] = defaultdict(float)
by_attribution: dict[tuple[str, str], dict] = {}
for line in lines:
bucket = by_service.setdefault(
line.service_id,
@ -37,11 +53,31 @@ def build_service_cost_report(
bucket["months"][line.period_month] = month_total
bucket["total"] += line.amount
totals_by_month[line.period_month] += line.amount
key = (line.cost_attribution_key or "unattributed", line.currency)
attribution = by_attribution.setdefault(
key,
{
"cost_attribution_key": line.cost_attribution_key,
"client_id": line.client_id,
"application_id": line.application_id,
"app_instance_id": line.app_instance_id,
"currency": line.currency,
"months": {},
"total": 0.0,
},
)
attribution["months"][line.period_month] = (
attribution["months"].get(line.period_month, 0.0) + line.amount
)
attribution["total"] += line.amount
services = sorted(by_service.values(), key=lambda item: item["total"], reverse=True)
return {
"source_hub": "fin-hub",
"target_hub": "ops-hub",
"signal": "service_cost_attribution",
"services": services,
"attributions": sorted(
by_attribution.values(), key=lambda item: item["total"], reverse=True
),
"totals_by_month": dict(sorted(totals_by_month.items())),
}
}

View file

@ -6,6 +6,7 @@ from dataclasses import dataclass
from datetime import date
from pathlib import Path
from fin_hub.attribution import optional_attribution
from fin_hub.ingest._csv import parse_amount, pick, read_csv_rows
@ -19,6 +20,10 @@ class HostEuropeCostRow:
incurred_on: date | None
environment: str = "production"
source: str = "hosteurope"
client_id: str | None = None
application_id: str | None = None
app_instance_id: str | None = None
cost_attribution_key: str | None = None
def parse_hosteurope_csv(path: Path, *, default_currency: str = "EUR") -> list[HostEuropeCostRow]:
@ -38,6 +43,11 @@ def parse_hosteurope_csv(path: Path, *, default_currency: str = "EUR") -> list[H
continue
currency = pick(row, "currency") or default_currency
environment = pick(row, "environment", "env") or "production"
attribution = optional_attribution(
pick(row, "client_id", "client") or None,
pick(row, "application_id", "app_id", "application") or None,
pick(row, "app_instance_id", "instance_id", "instance") or None,
)
rows.append(
HostEuropeCostRow(
service_id=service_id,
@ -47,6 +57,10 @@ def parse_hosteurope_csv(path: Path, *, default_currency: str = "EUR") -> list[H
period_month=period[:7],
incurred_on=incurred_on,
environment=environment,
client_id=attribution.client_id if attribution else None,
application_id=attribution.application_id if attribution else None,
app_instance_id=attribution.app_instance_id if attribution else None,
cost_attribution_key=attribution.key if attribution else None,
)
)
return rows
return rows

View file

@ -5,15 +5,25 @@ from __future__ import annotations
import uuid
from datetime import date
from sqlalchemy import Date, Float, String
from sqlalchemy import CheckConstraint, Date, Float, String, event
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from hub_core.models.base import Base, TimestampMixin
from fin_hub.attribution import optional_attribution
class ServiceCost(Base, TimestampMixin):
__tablename__ = "fin_service_costs"
__table_args__ = (
CheckConstraint(
"(client_id IS NULL AND application_id IS NULL AND app_instance_id IS NULL "
"AND cost_attribution_key IS NULL) OR "
"(client_id IS NOT NULL AND application_id IS NOT NULL "
"AND app_instance_id IS NOT NULL AND cost_attribution_key IS NOT NULL)",
name="ck_fin_service_costs_complete_attribution",
),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
service_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
@ -23,4 +33,20 @@ class ServiceCost(Base, TimestampMixin):
currency: Mapped[str] = mapped_column(String(3), nullable=False, default="EUR")
source: Mapped[str] = mapped_column(String(32), nullable=False)
incurred_on: Mapped[date | None] = mapped_column(Date, nullable=True)
notes: Mapped[str | None] = mapped_column(String(512), nullable=True)
notes: Mapped[str | None] = mapped_column(String(512), nullable=True)
client_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
application_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
app_instance_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
cost_attribution_key: Mapped[str | None] = mapped_column(String(423), nullable=True, index=True)
@event.listens_for(ServiceCost, "before_insert")
@event.listens_for(ServiceCost, "before_update")
def _normalize_service_cost_attribution(_mapper, _connection, target: ServiceCost) -> None:
attribution = optional_attribution(
target.client_id, target.application_id, target.app_instance_id
)
expected_key = attribution.key if attribution else None
if target.cost_attribution_key not in (None, expected_key):
raise ValueError("cost_attribution_key does not match its attribution dimensions")
target.cost_attribution_key = expected_key

View file

@ -25,6 +25,10 @@ class LedgerEntry:
period_month: str
incurred_on: date | None
source_path: str
client_id: str | None = None
application_id: str | None = None
app_instance_id: str | None = None
cost_attribution_key: str | None = None
@dataclass(frozen=True)
@ -92,6 +96,15 @@ def _ensure_schema(conn: sqlite3.Connection) -> None:
);
"""
)
columns = {row["name"] for row in conn.execute("PRAGMA table_info(ledger_entries)")}
for name in (
"client_id",
"application_id",
"app_instance_id",
"cost_attribution_key",
):
if name not in columns:
conn.execute(f"ALTER TABLE ledger_entries ADD COLUMN {name} TEXT")
def _utc_now() -> str:
@ -171,6 +184,10 @@ def _entries_from_hosteurope(path: Path) -> list[LedgerEntry]:
period_month=row.period_month,
incurred_on=row.incurred_on,
source_path=resolved,
client_id=row.client_id,
application_id=row.application_id,
app_instance_id=row.app_instance_id,
cost_attribution_key=row.cost_attribution_key,
)
for row in parse_hosteurope_csv(path)
]
@ -217,8 +234,9 @@ def import_csv(
"""
INSERT INTO ledger_entries (
source_type, category, label, amount, currency,
period_month, incurred_on, source_path, imported_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
period_month, incurred_on, source_path, imported_at,
client_id, application_id, app_instance_id, cost_attribution_key
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
entry.source_type,
@ -230,6 +248,10 @@ def import_csv(
entry.incurred_on.isoformat() if entry.incurred_on else None,
entry.source_path,
imported_at,
entry.client_id,
entry.application_id,
entry.app_instance_id,
entry.cost_attribution_key,
),
)
conn.execute(
@ -300,4 +322,4 @@ def ledger_stats(*, ledger_path: Path | None = None) -> dict:
def ledger_stats_json(*, ledger_path: Path | None = None) -> str:
return json.dumps(ledger_stats(ledger_path=ledger_path), indent=2)
return json.dumps(ledger_stats(ledger_path=ledger_path), indent=2)