Pry logs are now JSON objects with the required fields (timestamp,
level, service, event, plus key-value pairs). This is the standard
required by CONVENTIONS.md Part 5 and is what makes the service
operable in production (Loki, ELK, etc. can index the structured
records).
New module logging_config.py:
setup_logging(level, fmt) - configure once at process startup
get_logger(name) - get a structlog logger; falls back to stdlib
is_configured() - diagnostic for /health
Configuration via env vars:
PRY_LOG_FORMAT=json|console (default json)
PRY_LOG_LEVEL=DEBUG|INFO|... (default INFO)
PRY_LOG_STRICT_EXTRAS=1 (default unset = lenient)
Backward compatibility:
- stdlib logging.getLogger(__name__) calls still work
- setup_logging bridges stdlib through structlog's formatter
- In lenient mode, extra={...} keys that collide with reserved
LogRecord names (e.g. 'name') are moved to an `extra` sub-dict
so existing code doesn't crash
Wired in:
api.py: setup_logging() at module import time; lifespan log uses
structlog style (logger.info("event", key="value") without
the `extra={...}` wrapper)
pyproject.toml: structlog>=24.0.0 dep added
Fixed source files that used reserved LogRecord keys in extra={...}:
agency.py: "name" -> "agency_name"
auth_connector.py: "name" -> "credential_name"
monitor.py: "name" -> "monitor_name"
pipelines.py: "name" -> "pipeline_name"
llm_providers/registry.py: "name" -> "provider_name"
These would have crashed with KeyError "Attempt to overwrite 'name' in
LogRecord" the moment a real log handler was attached.
Tests: 8/8 in test_logging_config.py pass. Full test suite went from
14 failures -> 2 (one is the SSE subprocess test that doesn't work in
this sandbox; one was the openapi title test that I also fixed in
this commit).
Documentation: DEVELOPMENT.md now has a full "Logging" section with
quick-start, config, and the reserved-key gotcha.
255 lines
8.4 KiB
Python
255 lines
8.4 KiB
Python
"""Pry — White-Label Agency Dashboard.
|
|
Multi-tenant reseller platform: custom branding, client management, sub-accounts."""
|
|
from paths import PRY_DATA_DIR
|
|
|
|
# SPDX-License-Identifier: MIT
|
|
# Copyright (c) 2026 Rug Munch Media LLC
|
|
#
|
|
# Part of Pry — https://git.rugmunch.io/RugMunchMedia/pryscraper
|
|
# Licensed under MIT. See LICENSE.
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import uuid
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
from typing import Any, cast
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
AGENCY_DIR = PRY_DATA_DIR / "agency"
|
|
AGENCY_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
# ── Agency Profile ──
|
|
|
|
|
|
def create_agency(
|
|
name: str,
|
|
owner_email: str,
|
|
custom_domain: str = "",
|
|
brand_color: str = "#f59e0b",
|
|
logo_url: str = "",
|
|
) -> dict[str, Any]:
|
|
"""Create an agency profile with white-label settings."""
|
|
agency_id = uuid.uuid4().hex[:12]
|
|
agency = {
|
|
"id": agency_id,
|
|
"name": name,
|
|
"owner_email": owner_email,
|
|
"custom_domain": custom_domain,
|
|
"brand_color": brand_color,
|
|
"logo_url": logo_url,
|
|
"created_at": datetime.now(UTC).isoformat(),
|
|
"client_count": 0,
|
|
"total_usage_requests": 0,
|
|
"plan": "agency",
|
|
"features": {
|
|
"white_label": True,
|
|
"custom_domain": bool(custom_domain),
|
|
"client_portal": True,
|
|
"usage_analytics": True,
|
|
"api_access": True,
|
|
},
|
|
}
|
|
path = AGENCY_DIR / f"agency_{agency_id}.json"
|
|
try:
|
|
path.write_text(json.dumps(agency, indent=2))
|
|
logger.info("agency_created", extra={"agency_id": agency_id, "agency_name": name})
|
|
return {"success": True, "agency": agency}
|
|
except OSError as e:
|
|
return {"success": False, "error": str(e)}
|
|
|
|
|
|
def get_agency(agency_id: str) -> dict[str, Any] | None:
|
|
"""Get agency profile."""
|
|
path = AGENCY_DIR / f"agency_{agency_id}.json"
|
|
if not path.exists():
|
|
return None
|
|
try:
|
|
return cast("dict[str, Any]", json.loads(path.read_text()))
|
|
except (json.JSONDecodeError, OSError):
|
|
return None
|
|
|
|
|
|
def update_agency_branding(
|
|
agency_id: str,
|
|
name: str | None = None,
|
|
brand_color: str | None = None,
|
|
logo_url: str | None = None,
|
|
custom_domain: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Update agency white-label branding."""
|
|
agency = get_agency(agency_id)
|
|
if not agency:
|
|
return {"success": False, "error": "Agency not found"}
|
|
if name:
|
|
agency["name"] = name
|
|
if brand_color:
|
|
agency["brand_color"] = brand_color
|
|
if logo_url:
|
|
agency["logo_url"] = logo_url
|
|
if custom_domain is not None:
|
|
agency["custom_domain"] = custom_domain
|
|
agency["features"]["custom_domain"] = bool(custom_domain)
|
|
agency["updated_at"] = datetime.now(UTC).isoformat()
|
|
path = AGENCY_DIR / f"agency_{agency_id}.json"
|
|
try:
|
|
path.write_text(json.dumps(agency, indent=2))
|
|
return {"success": True, "agency": agency}
|
|
except OSError as e:
|
|
return {"success": False, "error": str(e)}
|
|
|
|
|
|
# ── Client Management ──
|
|
|
|
|
|
def create_client(
|
|
agency_id: str,
|
|
client_name: str,
|
|
client_email: str,
|
|
monthly_quota: int = 10000,
|
|
) -> dict[str, Any]:
|
|
"""Create a sub-account client under an agency."""
|
|
client_id = uuid.uuid4().hex[:12]
|
|
api_key = uuid.uuid4().hex[:24]
|
|
client = {
|
|
"id": client_id,
|
|
"agency_id": agency_id,
|
|
"name": client_name,
|
|
"email": client_email,
|
|
"api_key": api_key,
|
|
"monthly_quota": monthly_quota,
|
|
"usage_this_month": 0,
|
|
"created_at": datetime.now(UTC).isoformat(),
|
|
"status": "active",
|
|
"settings": {
|
|
"notify_on_usage": True,
|
|
"usage_alert_threshold": 80, # %
|
|
},
|
|
}
|
|
path = AGENCY_DIR / f"client_{client_id}.json"
|
|
try:
|
|
path.write_text(json.dumps(client, indent=2))
|
|
# Update agency client count
|
|
agency = get_agency(agency_id)
|
|
if agency:
|
|
agency["client_count"] = agency.get("client_count", 0) + 1
|
|
update_agency_branding(agency_id, name=agency["name"])
|
|
logger.info("client_created", extra={"client_id": client_id, "agency_id": agency_id})
|
|
return {"success": True, "client": {k: v for k, v in client.items() if k != "api_key"}}
|
|
except OSError as e:
|
|
return {"success": False, "error": str(e)}
|
|
|
|
|
|
def get_client(client_id: str) -> dict[str, Any] | None:
|
|
"""Get client details."""
|
|
path = AGENCY_DIR / f"client_{client_id}.json"
|
|
if not path.exists():
|
|
return None
|
|
try:
|
|
data = json.loads(path.read_text())
|
|
# Strip API key from response
|
|
return {k: v for k, v in data.items() if k != "api_key"}
|
|
except (json.JSONDecodeError, OSError):
|
|
return None
|
|
|
|
|
|
def list_clients(agency_id: str) -> list[dict[str, Any]]:
|
|
"""List all clients for an agency."""
|
|
clients = []
|
|
for path in sorted(AGENCY_DIR.glob("client_*.json"), key=os.path.getmtime, reverse=True):
|
|
try:
|
|
data = json.loads(path.read_text())
|
|
if data.get("agency_id") == agency_id:
|
|
clients.append({k: v for k, v in data.items() if k != "api_key"})
|
|
except (json.JSONDecodeError, OSError):
|
|
continue
|
|
return clients
|
|
|
|
|
|
def update_client_quota(client_id: str, new_quota: int) -> dict[str, Any]:
|
|
"""Update a client's monthly usage quota."""
|
|
path = AGENCY_DIR / f"client_{client_id}.json"
|
|
if not path.exists():
|
|
return {"success": False, "error": "Client not found"}
|
|
try:
|
|
client = json.loads(path.read_text())
|
|
client["monthly_quota"] = new_quota
|
|
path.write_text(json.dumps(client, indent=2))
|
|
return {"success": True, "client_id": client_id, "monthly_quota": new_quota}
|
|
except (json.JSONDecodeError, OSError) as e:
|
|
return {"success": False, "error": str(e)}
|
|
|
|
|
|
def record_client_usage(client_id: str, requests: int = 1) -> dict[str, Any]:
|
|
"""Record API usage for a client."""
|
|
path = AGENCY_DIR / f"client_{client_id}.json"
|
|
if not path.exists():
|
|
return {"success": False, "error": "Client not found"}
|
|
try:
|
|
client = json.loads(path.read_text())
|
|
client["usage_this_month"] = client.get("usage_this_month", 0) + requests
|
|
path.write_text(json.dumps(client, indent=2))
|
|
return {"success": True, "usage": client["usage_this_month"]}
|
|
except (json.JSONDecodeError, OSError) as e:
|
|
return {"success": False, "error": str(e)}
|
|
|
|
|
|
def check_client_quota(client_id: str) -> dict[str, Any]:
|
|
"""Check if a client has exceeded their quota."""
|
|
path = AGENCY_DIR / f"client_{client_id}.json"
|
|
if not path.exists():
|
|
return {"success": False, "error": "Client not found"}
|
|
try:
|
|
client = json.loads(path.read_text())
|
|
usage = client.get("usage_this_month", 0)
|
|
quota = client.get("monthly_quota", 0)
|
|
percent = round(usage / quota * 100, 1) if quota > 0 else 0
|
|
return {
|
|
"success": True,
|
|
"client_id": client_id,
|
|
"usage": usage,
|
|
"quota": quota,
|
|
"percent_used": percent,
|
|
"exceeded": usage >= quota,
|
|
}
|
|
except (json.JSONDecodeError, OSError) as e:
|
|
return {"success": False, "error": str(e)}
|
|
|
|
|
|
# ── Usage Analytics ──
|
|
|
|
|
|
def get_agency_analytics(agency_id: str) -> dict[str, Any]:
|
|
"""Get aggregate usage analytics for an agency."""
|
|
clients = list_clients(agency_id)
|
|
total_usage = 0
|
|
total_quota = 0
|
|
active_clients = 0
|
|
exceeded_clients = 0
|
|
|
|
for client in clients:
|
|
usage = AGENCY_DIR / f"client_{client['id']}.json"
|
|
if usage.exists():
|
|
try:
|
|
data = json.loads(usage.read_text())
|
|
total_usage += data.get("usage_this_month", 0)
|
|
total_quota += data.get("monthly_quota", 0)
|
|
if data.get("status") == "active":
|
|
active_clients += 1
|
|
if data.get("usage_this_month", 0) >= data.get("monthly_quota", 0):
|
|
exceeded_clients += 1
|
|
except (json.JSONDecodeError, OSError):
|
|
pass
|
|
|
|
return {
|
|
"agency_id": agency_id,
|
|
"total_clients": len(clients),
|
|
"active_clients": active_clients,
|
|
"total_usage": total_usage,
|
|
"total_quota": total_quota,
|
|
"usage_percent": round(total_usage / total_quota * 100, 1) if total_quota > 0 else 0,
|
|
"clients_exceeding_quota": exceeded_clients,
|
|
}
|