Implement client cost attribution
This commit is contained in:
parent
d2b9bc4b32
commit
33883e0977
15 changed files with 362 additions and 21 deletions
59
src/fin_hub/attribution.py
Normal file
59
src/fin_hub/attribution.py
Normal 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 "")
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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())),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue