pryscraper/observability.py
cryptorugmunch bb77eb5f35 chore(license): re-license to dual MIT (core) + BSL 1.1 (stealth)
Re-license Pry from full Proprietary to a dual-license model:

- Core engine, extraction, templates (80+), MCP server, x402 payment rail,
  CLI, SDK, browser extension, WordPress plugin, Shopify app, and
  llm_providers: MIT (see LICENSE)
- Anti-detection / stealth subset (15 files): BSL 1.1 with Change Date
  2029-01-01 (see LICENSE-BSL-STEALTH)

BSL files (anti-detection moat):
  ultimate_scraper.py, stealth_engine.py, stealth_scripts/*.js (6),
  camoufox_integration.py, tls_fingerprint.py, cookie_warmer.py,
  behavioral_biometrics.py, adaptive.py, browser_pool.py, network.py,
  captcha_solver.py, shadow_dom.py, lazy_load.py, signup_automator.py,
  auth_connector.py

This enables community contributions to the core engine (templates,
integrations, MCP tools) while protecting the anti-detection techniques
that constitute the actual competitive moat. BSL Additional Use Grant
permits free non-production use; production deployment requires a
commercial license from enterprise@rugmunch.io.

Changes:
- Replace proprietary LICENSE with MIT LICENSE + new LICENSE-BSL-STEALTH
- Add SPDX-License-Identifier headers to 300+ source files
- Add docs/adr/0002-dual-licensing.md (ADR documenting the decision)
- Update README.md: new License section with BSL Additional Use Grant
- Update LICENSING_PRICING_STRATEGY.md: Section 3 (PryScraper) for dual license
- Update AGENTS.md: license line in header + new rule 8 (PRs touching BSL rejected)
- Update pyproject.toml: license = "MIT AND BSL-1.1" + classifiers + license-files
- Update DECISIONS.md index with ADR-0002
- Update STATUS.md (2026-07-03) and PLAN.md sprint goals

Refs: ADR-0002
2026-07-02 19:49:21 +02:00

110 lines
4 KiB
Python

# 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.
"""Pry — Observability: Prometheus metrics, OpenTelemetry tracing, structured logging."""
import logging
import time
from contextlib import contextmanager
logger = logging.getLogger(__name__)
# Try to import Prometheus
try:
from prometheus_client import CONTENT_TYPE_LATEST, Counter, Gauge, Histogram, generate_latest
_has_prometheus = True
except ImportError:
_has_prometheus = False
# Try to import OpenTelemetry
try:
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor
_has_otel = True
except ImportError:
_has_otel = False
# Metrics
if _has_prometheus:
REQUEST_COUNT = Counter("pry_requests_total", "Total requests", ["method", "endpoint", "status"])
REQUEST_LATENCY = Histogram("pry_request_duration_seconds", "Request latency", ["endpoint"])
SCRAPE_COUNT = Counter("pry_scrapes_total", "Total scrapes", ["method", "status"])
SCRAPE_LATENCY = Histogram("pry_scrape_duration_seconds", "Scrape latency", ["method"])
LLM_CALLS = Counter("pry_llm_calls_total", "Total LLM calls", ["provider", "model"])
LLM_COST = Counter("pry_llm_cost_usd_total", "Total LLM cost in USD", ["provider"])
TEMPLATE_USAGE = Counter("pry_template_usage_total", "Template usage", ["template_id"])
CACHE_HITS = Counter("pry_cache_hits_total", "Cache hits", ["cache_type"])
ACTIVE_CONNECTIONS = Gauge("pry_active_connections", "Active connections", ["type"])
else:
REQUEST_COUNT = REQUEST_LATENCY = SCRAPE_COUNT = SCRAPE_LATENCY = None
LLM_CALLS = LLM_COST = TEMPLATE_USAGE = CACHE_HITS = ACTIVE_CONNECTIONS = None
def setup_tracing(service_name: str = "pry") -> None:
"""Initialize OpenTelemetry tracing."""
if not _has_otel: return
try:
provider = TracerProvider()
processor = SimpleSpanProcessor(ConsoleSpanExporter())
provider.add_span_processor(processor)
trace.set_tracer_provider(provider)
logger.info("tracing_initialized", extra={"service": service_name})
except Exception as e:
logger.warning("tracing_init_failed", extra={"error": str(e)[:80]})
@contextmanager
def track_request(endpoint: str, method: str = "GET"):
"""Context manager to track request metrics and timing."""
start = time.time()
status = "success"
try:
yield
except Exception:
status = "error"
raise
finally:
elapsed = time.time() - start
if _has_prometheus and REQUEST_COUNT and REQUEST_LATENCY:
REQUEST_COUNT.labels(method=method, endpoint=endpoint, status=status).inc()
REQUEST_LATENCY.labels(endpoint=endpoint).observe(elapsed)
@contextmanager
def track_scrape(method: str = "direct"):
"""Context manager to track scrape metrics."""
start = time.time()
status = "success"
try:
yield
except Exception:
status = "error"
raise
finally:
elapsed = time.time() - start
if _has_prometheus and SCRAPE_COUNT and SCRAPE_LATENCY:
SCRAPE_COUNT.labels(method=method, status=status).inc()
SCRAPE_LATENCY.labels(method=method).observe(elapsed)
def track_llm_call(provider: str, model: str, cost: float = 0.0) -> None:
if _has_prometheus and LLM_CALLS and LLM_COST:
LLM_CALLS.labels(provider=provider, model=model).inc()
if cost > 0:
LLM_COST.labels(provider=provider).inc(cost)
def track_template(template_id: str) -> None:
if _has_prometheus and TEMPLATE_USAGE:
TEMPLATE_USAGE.labels(template_id=template_id).inc()
def get_metrics_output() -> tuple[bytes, str]:
"""Return Prometheus metrics in text format."""
if not _has_prometheus:
return b"# Prometheus not installed\n", "text/plain"
return generate_latest(), CONTENT_TYPE_LATEST