feat: add observability and CLI enhancements

Audit logging:
- audit_log table with event tracking
- app/audit.py module with log_event(), query_audit_log()
- GET /audit endpoint (admin only)
- configurable retention and cleanup

Prometheus metrics:
- app/metrics.py with custom counters
- paste create/access/delete, rate limit, PoW, dedup metrics
- instrumentation in API routes

CLI clipboard integration:
- fpaste create -C/--clipboard (read from clipboard)
- fpaste create --copy-url (copy result URL)
- fpaste get -c/--copy (copy content)
- cross-platform: xclip, xsel, pbcopy, wl-copy

Shell completions:
- completions/ directory with bash/zsh/fish scripts
- fpaste completion --shell command
This commit is contained in:
Username
2025-12-23 22:39:50 +01:00
parent 4d08a4467d
commit 7063f8718e
13 changed files with 2003 additions and 47 deletions

View File

@@ -190,6 +190,11 @@ def setup_metrics(app: Flask) -> None:
metrics.info("flaskpaste_info", "FlaskPaste application info", version=VERSION)
app.extensions["metrics"] = metrics
# Setup custom metrics
from app.metrics import setup_custom_metrics
setup_custom_metrics(app)
except ImportError:
app.logger.warning("prometheus_flask_exporter not available, metrics disabled")

View File

@@ -13,11 +13,13 @@ _cleanup_times: dict[str, float] = {
"pastes": 0.0,
"hashes": 0.0,
"rate_limits": 0.0,
"audit": 0.0,
}
_CLEANUP_INTERVALS = {
"pastes": 3600, # 1 hour
"hashes": 900, # 15 minutes
"rate_limits": 300, # 5 minutes
"audit": 86400, # 24 hours
}
@@ -61,5 +63,15 @@ def run_scheduled_cleanup():
if count > 0:
current_app.logger.info(f"Cleaned up {count} rate limit entries")
# Cleanup old audit logs
if now - _cleanup_times["audit"] >= _CLEANUP_INTERVALS["audit"]:
_cleanup_times["audit"] = now
if current_app.config.get("AUDIT_ENABLED", True):
from app.audit import cleanup_old_audit_logs
count = cleanup_old_audit_logs()
if count > 0:
current_app.logger.info(f"Cleaned up {count} old audit log entries")
from app.api import routes # noqa: E402, F401

View File

@@ -19,6 +19,14 @@ from flask.views import MethodView
from app.api import bp
from app.config import VERSION
from app.database import check_content_hash, get_db, hash_password, verify_password
from app.metrics import (
record_dedup,
record_paste_accessed,
record_paste_created,
record_paste_deleted,
record_pow,
record_rate_limit,
)
if TYPE_CHECKING:
from sqlite3 import Row
@@ -246,6 +254,16 @@ def prefixed_url(path: str) -> str:
return f"{url_prefix()}{path}"
def paste_url(paste_id: str) -> str:
"""Generate URL for paste metadata endpoint."""
return prefixed_url(f"/{paste_id}")
def paste_raw_url(paste_id: str) -> str:
"""Generate URL for raw paste content endpoint."""
return prefixed_url(f"/{paste_id}/raw")
def base_url() -> str:
"""Detect full base URL from request headers."""
scheme = (
@@ -257,6 +275,59 @@ def base_url() -> str:
return f"{scheme}://{host}{url_prefix()}"
# ─────────────────────────────────────────────────────────────────────────────
# Response Builders
# ─────────────────────────────────────────────────────────────────────────────
def build_paste_metadata(
paste_id: str,
mime_type: str,
size: int,
created_at: int,
*,
owner: str | None = None,
burn_after_read: bool = False,
expires_at: int | None = None,
password_protected: bool = False,
include_owner: bool = False,
last_accessed: int | None = None,
) -> dict[str, Any]:
"""Build standardized paste metadata response dict.
Args:
paste_id: Paste identifier
mime_type: Content MIME type
size: Content size in bytes
created_at: Creation timestamp
owner: Owner fingerprint (included only if include_owner=True)
burn_after_read: Whether paste is burn-after-read
expires_at: Expiration timestamp
password_protected: Whether paste has password
include_owner: Whether to include owner in response
last_accessed: Last access timestamp (optional)
"""
data: dict[str, Any] = {
"id": paste_id,
"mime_type": mime_type,
"size": size,
"created_at": created_at,
"url": paste_url(paste_id),
"raw": paste_raw_url(paste_id),
}
if last_accessed is not None:
data["last_accessed"] = last_accessed
if include_owner and owner:
data["owner"] = owner
if burn_after_read:
data["burn_after_read"] = True
if expires_at:
data["expires_at"] = expires_at
if password_protected:
data["password_protected"] = True
return data
# ─────────────────────────────────────────────────────────────────────────────
# Validation Helpers (used within views)
# ─────────────────────────────────────────────────────────────────────────────
@@ -703,6 +774,17 @@ class IndexView(MethodView):
current_app.logger.warning(
"Rate limit exceeded: ip=%s trusted=%s", client_ip, bool(trusted_client)
)
# Audit log rate limit event
if current_app.config.get("AUDIT_ENABLED", True):
from app.audit import AuditEvent, AuditOutcome, log_event
log_event(
AuditEvent.RATE_LIMIT,
AuditOutcome.BLOCKED,
client_id=owner,
client_ip=client_ip,
)
record_rate_limit("blocked")
response = error_response(
"Rate limit exceeded",
429,
@@ -729,8 +811,22 @@ class IndexView(MethodView):
current_app.logger.warning(
"PoW verification failed: %s from=%s", err, request.remote_addr
)
# Audit log PoW failure
if current_app.config.get("AUDIT_ENABLED", True):
from app.audit import AuditEvent, AuditOutcome, log_event
log_event(
AuditEvent.POW_FAILURE,
AuditOutcome.FAILURE,
client_id=owner,
client_ip=client_ip,
details={"error": err},
)
record_pow("failure")
return error_response(f"Proof-of-work failed: {err}", 400)
record_pow("success")
# Size limits (only trusted clients get elevated limits)
content_size = len(content)
max_size = (
@@ -807,6 +903,18 @@ class IndexView(MethodView):
dedup_count,
request.remote_addr,
)
# Audit log dedup block
if current_app.config.get("AUDIT_ENABLED", True):
from app.audit import AuditEvent, AuditOutcome, log_event
log_event(
AuditEvent.DEDUP_BLOCK,
AuditOutcome.BLOCKED,
client_id=owner,
client_ip=client_ip,
details={"hash": content_hash[:16], "count": dedup_count},
)
record_dedup("blocked")
return error_response(
"Duplicate content rate limit exceeded",
429,
@@ -814,6 +922,8 @@ class IndexView(MethodView):
window_seconds=window,
)
record_dedup("allowed")
# Parse optional headers
burn_header = request.headers.get("X-Burn-After-Read", "").strip().lower()
burn_after_read = burn_header in ("true", "1", "yes")
@@ -883,11 +993,11 @@ class IndexView(MethodView):
)
db.commit()
# Build response
# Build response (creation response is intentionally different - no size)
response_data: dict[str, Any] = {
"id": paste_id,
"url": f"/{paste_id}",
"raw": f"/{paste_id}/raw",
"url": paste_url(paste_id),
"raw": paste_raw_url(paste_id),
"mime_type": mime_type,
"created_at": now,
}
@@ -903,6 +1013,21 @@ class IndexView(MethodView):
# Record successful paste for anti-flood tracking
record_antiflood_request()
# Audit log paste creation
if current_app.config.get("AUDIT_ENABLED", True):
from app.audit import AuditEvent, AuditOutcome, log_event
log_event(
AuditEvent.PASTE_CREATE,
AuditOutcome.SUCCESS,
paste_id=paste_id,
client_id=owner,
client_ip=client_ip,
details={"size": content_size, "mime_type": mime_type},
)
record_paste_created("authenticated" if owner else "anonymous", "success")
return json_response(response_data, 201)
@@ -1135,21 +1260,17 @@ class PasteView(MethodView):
row: Row = g.paste
g.db.commit()
response_data: dict[str, Any] = {
"id": row["id"],
"mime_type": row["mime_type"],
"size": row["size"],
"created_at": row["created_at"],
"raw": f"/{paste_id}/raw",
}
if row["burn_after_read"]:
response_data["burn_after_read"] = True
if row["expires_at"]:
response_data["expires_at"] = row["expires_at"]
if row["password_hash"]:
response_data["password_protected"] = True
return json_response(response_data)
return json_response(
build_paste_metadata(
paste_id=row["id"],
mime_type=row["mime_type"],
size=row["size"],
created_at=row["created_at"],
burn_after_read=bool(row["burn_after_read"]),
expires_at=row["expires_at"],
password_protected=bool(row["password_hash"]),
)
)
def head(self, paste_id: str) -> Response:
"""Return paste metadata headers only."""
@@ -1254,6 +1375,19 @@ class PasteView(MethodView):
db.execute(update_sql, update_params)
db.commit()
# Audit log paste update
if current_app.config.get("AUDIT_ENABLED", True):
from app.audit import AuditEvent, AuditOutcome, log_event
log_event(
AuditEvent.PASTE_UPDATE,
AuditOutcome.SUCCESS,
paste_id=paste_id,
client_id=g.client_id,
client_ip=get_client_ip(),
details={"fields": [f.split(" = ")[0] for f in update_fields]},
)
# Fetch updated paste for response
updated = db.execute(
"""SELECT id, mime_type, length(content) as size, expires_at,
@@ -1296,6 +1430,24 @@ class PasteRawView(MethodView):
db.commit()
# Audit log paste access
if current_app.config.get("AUDIT_ENABLED", True):
from app.audit import AuditEvent, AuditOutcome, log_event
log_event(
AuditEvent.PASTE_ACCESS,
AuditOutcome.SUCCESS,
paste_id=paste_id,
client_id=get_client_fingerprint(),
client_ip=get_client_ip(),
details={"burn": bool(burn_after_read)},
)
record_paste_accessed(
"authenticated" if get_client_fingerprint() else "anonymous",
bool(burn_after_read),
)
response = Response(row["content"], mimetype=row["mime_type"])
if row["mime_type"].startswith(("image/", "text/")):
response.headers["Content-Disposition"] = "inline"
@@ -1350,6 +1502,20 @@ class PasteDeleteView(MethodView):
db.execute("DELETE FROM pastes WHERE id = ?", (paste_id,))
db.commit()
# Audit log paste deletion
if current_app.config.get("AUDIT_ENABLED", True):
from app.audit import AuditEvent, AuditOutcome, log_event
log_event(
AuditEvent.PASTE_DELETE,
AuditOutcome.SUCCESS,
paste_id=paste_id,
client_id=g.client_id,
client_ip=get_client_ip(),
)
record_paste_deleted("authenticated", "success")
return json_response({"message": "Paste deleted"})
@@ -1457,27 +1623,21 @@ class PastesListView(MethodView):
if type_filter:
rows = [r for r in rows if fnmatch.fnmatch(r["mime_type"], type_filter)]
pastes = []
for row in rows:
paste: dict[str, Any] = {
"id": row["id"],
"mime_type": row["mime_type"],
"size": row["size"],
"created_at": row["created_at"],
"last_accessed": row["last_accessed"],
"url": f"/{row['id']}",
"raw": f"/{row['id']}/raw",
}
# Include owner for admin view
if show_all and row["owner"]:
paste["owner"] = row["owner"]
if row["burn_after_read"]:
paste["burn_after_read"] = True
if row["expires_at"]:
paste["expires_at"] = row["expires_at"]
if row["password_protected"]:
paste["password_protected"] = True
pastes.append(paste)
pastes = [
build_paste_metadata(
paste_id=row["id"],
mime_type=row["mime_type"],
size=row["size"],
created_at=row["created_at"],
owner=row["owner"],
burn_after_read=bool(row["burn_after_read"]),
expires_at=row["expires_at"],
password_protected=bool(row["password_protected"]),
include_owner=show_all,
last_accessed=row["last_accessed"],
)
for row in rows
]
response_data: dict[str, Any] = {
"pastes": pastes,
@@ -1780,6 +1940,79 @@ class PKIRevokeView(MethodView):
return json_response({"message": "Certificate revoked", "serial": serial})
# ─────────────────────────────────────────────────────────────────────────────
# Audit Log View
# ─────────────────────────────────────────────────────────────────────────────
class AuditLogView(MethodView):
"""Audit log query endpoint (admin only)."""
def get(self) -> Response:
"""Query audit log with filters.
Query parameters:
- event_type: Filter by event type
- client_id: Filter by client fingerprint
- paste_id: Filter by paste ID
- outcome: Filter by outcome (success, failure, blocked)
- since: Filter by timestamp >= since
- until: Filter by timestamp <= until
- limit: Maximum results (default 100, max 500)
- offset: Pagination offset
"""
if err := require_auth():
return err
if not is_admin():
return error_response("Admin access required", 403)
from app.audit import query_audit_log
# Parse query parameters
event_type = request.args.get("event_type", "").strip() or None
client_id = request.args.get("client_id", "").strip() or None
paste_id = request.args.get("paste_id", "").strip() or None
outcome = request.args.get("outcome", "").strip() or None
try:
since = int(request.args.get("since", 0)) or None
except (ValueError, TypeError):
since = None
try:
until = int(request.args.get("until", 0)) or None
except (ValueError, TypeError):
until = None
try:
limit = min(int(request.args.get("limit", 100)), 500)
except (ValueError, TypeError):
limit = 100
try:
offset = max(int(request.args.get("offset", 0)), 0)
except (ValueError, TypeError):
offset = 0
entries, total = query_audit_log(
event_type=event_type,
client_id=client_id,
paste_id=paste_id,
outcome=outcome,
since=since,
until=until,
limit=limit,
offset=offset,
)
return json_response(
{
"entries": entries,
"count": len(entries),
"total": total,
"limit": limit,
"offset": offset,
}
)
# ─────────────────────────────────────────────────────────────────────────────
# Route Registration
# ─────────────────────────────────────────────────────────────────────────────
@@ -1817,3 +2050,6 @@ bp.add_url_rule("/pki/certs", view_func=PKICertsView.as_view("pki_certs"))
bp.add_url_rule(
"/pki/revoke/<serial>", view_func=PKIRevokeView.as_view("pki_revoke"), methods=["POST"]
)
# Audit log endpoint (admin only)
bp.add_url_rule("/audit", view_func=AuditLogView.as_view("audit_log"))

206
app/audit.py Normal file
View File

@@ -0,0 +1,206 @@
"""Audit logging for security events."""
from __future__ import annotations
import json
import time
from enum import Enum
from typing import TYPE_CHECKING, Any
from flask import current_app, g
from app.database import get_db
if TYPE_CHECKING:
from sqlite3 import Row
class AuditEvent(str, Enum):
"""Security-relevant event types for audit logging."""
PASTE_CREATE = "paste_create"
PASTE_ACCESS = "paste_access"
PASTE_DELETE = "paste_delete"
PASTE_UPDATE = "paste_update"
RATE_LIMIT = "rate_limit"
POW_FAILURE = "pow_failure"
DEDUP_BLOCK = "dedup_block"
CERT_ISSUED = "cert_issued"
CERT_REVOKED = "cert_revoked"
AUTH_FAILURE = "auth_failure"
class AuditOutcome(str, Enum):
"""Outcome types for audit events."""
SUCCESS = "success"
FAILURE = "failure"
BLOCKED = "blocked"
def log_event(
event_type: AuditEvent | str,
outcome: AuditOutcome | str,
*,
paste_id: str | None = None,
client_id: str | None = None,
client_ip: str | None = None,
details: dict[str, Any] | None = None,
) -> None:
"""Log a security-relevant event to the audit log.
Args:
event_type: Type of event (from AuditEvent enum or string)
outcome: Result of the event (success, failure, blocked)
paste_id: Related paste ID (if applicable)
client_id: Client certificate fingerprint (if authenticated)
client_ip: Client IP address
details: Additional event-specific details (stored as JSON)
"""
if not current_app.config.get("AUDIT_ENABLED", True):
return
now = int(time.time())
request_id = getattr(g, "request_id", None)
# Convert enums to strings
event_type_str = event_type.value if isinstance(event_type, AuditEvent) else event_type
outcome_str = outcome.value if isinstance(outcome, AuditOutcome) else outcome
# Serialize details to JSON if provided
details_json = json.dumps(details, ensure_ascii=False) if details else None
db = get_db()
db.execute(
"""INSERT INTO audit_log
(timestamp, event_type, client_id, client_ip, paste_id, request_id, outcome, details)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(
now,
event_type_str,
client_id,
client_ip,
paste_id,
request_id,
outcome_str,
details_json,
),
)
db.commit()
def query_audit_log(
*,
event_type: str | None = None,
client_id: str | None = None,
paste_id: str | None = None,
outcome: str | None = None,
since: int | None = None,
until: int | None = None,
limit: int = 100,
offset: int = 0,
) -> tuple[list[dict[str, Any]], int]:
"""Query audit log with filters.
Args:
event_type: Filter by event type
client_id: Filter by client fingerprint
paste_id: Filter by paste ID
outcome: Filter by outcome (success, failure, blocked)
since: Filter by timestamp >= since
until: Filter by timestamp <= until
limit: Maximum results to return
offset: Pagination offset
Returns:
Tuple of (list of audit entries, total count matching filters)
"""
db = get_db()
where_clauses: list[str] = []
params: list[Any] = []
if event_type:
where_clauses.append("event_type = ?")
params.append(event_type)
if client_id:
where_clauses.append("client_id = ?")
params.append(client_id)
if paste_id:
where_clauses.append("paste_id = ?")
params.append(paste_id)
if outcome:
where_clauses.append("outcome = ?")
params.append(outcome)
if since:
where_clauses.append("timestamp >= ?")
params.append(since)
if until:
where_clauses.append("timestamp <= ?")
params.append(until)
where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
# Get total count
count_row = db.execute(
f"SELECT COUNT(*) as total FROM audit_log WHERE {where_sql}", # noqa: S608
params,
).fetchone()
total = count_row["total"] if count_row else 0
# Fetch entries
rows: list[Row] = db.execute(
f"""SELECT id, timestamp, event_type, client_id, client_ip,
paste_id, request_id, outcome, details
FROM audit_log
WHERE {where_sql}
ORDER BY timestamp DESC
LIMIT ? OFFSET ?""", # noqa: S608
[*params, limit, offset],
).fetchall()
entries = []
for row in rows:
entry: dict[str, Any] = {
"id": row["id"],
"timestamp": row["timestamp"],
"event_type": row["event_type"],
"outcome": row["outcome"],
}
if row["client_id"]:
entry["client_id"] = row["client_id"]
if row["client_ip"]:
entry["client_ip"] = row["client_ip"]
if row["paste_id"]:
entry["paste_id"] = row["paste_id"]
if row["request_id"]:
entry["request_id"] = row["request_id"]
if row["details"]:
try:
entry["details"] = json.loads(row["details"])
except json.JSONDecodeError:
entry["details"] = row["details"]
entries.append(entry)
return entries, total
def cleanup_old_audit_logs(retention_days: int | None = None) -> int:
"""Delete audit log entries older than retention period.
Args:
retention_days: Number of days to keep. If None, uses config.
Returns:
Number of deleted entries.
"""
if retention_days is None:
retention_days = current_app.config.get("AUDIT_RETENTION_DAYS", 90)
cutoff = int(time.time()) - (retention_days * 24 * 60 * 60)
db = get_db()
cursor = db.execute("DELETE FROM audit_log WHERE timestamp < ?", (cutoff,))
db.commit()
return cursor.rowcount

View File

@@ -100,6 +100,11 @@ class Config:
# Authenticated users get higher limits (multiplier)
RATE_LIMIT_AUTH_MULTIPLIER = int(os.environ.get("FLASKPASTE_RATE_AUTH_MULT", "5"))
# Audit Logging
# Track security-relevant events (paste creation, deletion, rate limits, etc.)
AUDIT_ENABLED = os.environ.get("FLASKPASTE_AUDIT", "1").lower() in ("1", "true", "yes")
AUDIT_RETENTION_DAYS = int(os.environ.get("FLASKPASTE_AUDIT_RETENTION", "90"))
# PKI Configuration
# Enable PKI endpoints for certificate authority and issuance
PKI_ENABLED = os.environ.get("FLASKPASTE_PKI_ENABLED", "0").lower() in ("1", "true", "yes")

View File

@@ -73,6 +73,23 @@ CREATE TABLE IF NOT EXISTS issued_certificates (
CREATE INDEX IF NOT EXISTS idx_certs_fingerprint ON issued_certificates(fingerprint_sha1);
CREATE INDEX IF NOT EXISTS idx_certs_status ON issued_certificates(status);
CREATE INDEX IF NOT EXISTS idx_certs_ca_id ON issued_certificates(ca_id);
-- Audit log for security events
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER NOT NULL,
event_type TEXT NOT NULL,
client_id TEXT,
client_ip TEXT,
paste_id TEXT,
request_id TEXT,
outcome TEXT NOT NULL,
details TEXT
);
CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(timestamp);
CREATE INDEX IF NOT EXISTS idx_audit_event_type ON audit_log(event_type);
CREATE INDEX IF NOT EXISTS idx_audit_client_id ON audit_log(client_id);
"""
# Password hashing constants

160
app/metrics.py Normal file
View File

@@ -0,0 +1,160 @@
"""Custom Prometheus metrics for FlaskPaste."""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from flask import Flask
# Metrics are only initialized when not testing
_paste_created_counter = None
_paste_accessed_counter = None
_paste_deleted_counter = None
_rate_limit_counter = None
_pow_counter = None
_dedup_counter = None
_request_duration_histogram = None
def setup_custom_metrics(app: Flask) -> None:
"""Initialize custom Prometheus metrics.
Should be called after prometheus_flask_exporter is set up.
"""
global _paste_created_counter, _paste_accessed_counter, _paste_deleted_counter
global _rate_limit_counter, _pow_counter, _dedup_counter, _request_duration_histogram
if app.config.get("TESTING"):
return
try:
from prometheus_client import Counter, Histogram
except ImportError:
app.logger.warning("prometheus_client not available, custom metrics disabled")
return
_paste_created_counter = Counter(
"flaskpaste_paste_created_total",
"Total number of paste creation attempts",
["auth_type", "outcome"],
)
_paste_accessed_counter = Counter(
"flaskpaste_paste_accessed_total",
"Total number of paste accesses",
["auth_type", "burn"],
)
_paste_deleted_counter = Counter(
"flaskpaste_paste_deleted_total",
"Total number of paste deletion attempts",
["auth_type", "outcome"],
)
_rate_limit_counter = Counter(
"flaskpaste_rate_limit_total",
"Total number of rate limit events",
["outcome"],
)
_pow_counter = Counter(
"flaskpaste_pow_total",
"Total number of proof-of-work validations",
["outcome"],
)
_dedup_counter = Counter(
"flaskpaste_dedup_total",
"Total number of content deduplication events",
["outcome"],
)
_request_duration_histogram = Histogram(
"flaskpaste_request_duration_seconds",
"Request duration in seconds",
["method", "endpoint", "status"],
buckets=(0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0),
)
app.logger.info("Custom Prometheus metrics initialized")
def record_paste_created(auth_type: str, outcome: str) -> None:
"""Record a paste creation attempt.
Args:
auth_type: "authenticated" or "anonymous"
outcome: "success", "failure", or "blocked"
"""
if _paste_created_counter:
_paste_created_counter.labels(auth_type=auth_type, outcome=outcome).inc()
def record_paste_accessed(auth_type: str, burn: bool) -> None:
"""Record a paste access.
Args:
auth_type: "authenticated" or "anonymous"
burn: True if paste was burn-after-read
"""
if _paste_accessed_counter:
_paste_accessed_counter.labels(auth_type=auth_type, burn=str(burn).lower()).inc()
def record_paste_deleted(auth_type: str, outcome: str) -> None:
"""Record a paste deletion attempt.
Args:
auth_type: "authenticated" or "anonymous"
outcome: "success" or "failure"
"""
if _paste_deleted_counter:
_paste_deleted_counter.labels(auth_type=auth_type, outcome=outcome).inc()
def record_rate_limit(outcome: str) -> None:
"""Record a rate limit event.
Args:
outcome: "allowed" or "blocked"
"""
if _rate_limit_counter:
_rate_limit_counter.labels(outcome=outcome).inc()
def record_pow(outcome: str) -> None:
"""Record a proof-of-work validation.
Args:
outcome: "success" or "failure"
"""
if _pow_counter:
_pow_counter.labels(outcome=outcome).inc()
def record_dedup(outcome: str) -> None:
"""Record a content deduplication event.
Args:
outcome: "allowed" or "blocked"
"""
if _dedup_counter:
_dedup_counter.labels(outcome=outcome).inc()
def observe_request_duration(
method: str, endpoint: str, status: int, duration: float
) -> None:
"""Record request duration.
Args:
method: HTTP method (GET, POST, etc.)
endpoint: Request endpoint path
status: HTTP status code
duration: Request duration in seconds
"""
if _request_duration_histogram:
_request_duration_histogram.labels(
method=method, endpoint=endpoint, status=str(status)
).observe(duration)