diff --git a/CLAUDE.md b/CLAUDE.md index d066beb..04cf43f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # recost — Python Middleware -Python SDK that automatically tracks outbound HTTP API calls, matches them against a built-in provider registry, aggregates events into time-windowed summaries, and ships telemetry to the ReCost cloud API or VS Code extension. +Python SDK that automatically tracks outbound HTTP API calls, matches them against a built-in provider registry, aggregates events into time-windowed summaries, and ships telemetry to the Recost cloud API or VS Code extension. ## Tech Stack @@ -16,21 +16,21 @@ Python SDK that automatically tracks outbound HTTP API calls, matches them again ``` recost/ __init__.py # Public API surface (re-exports only) - _init.py # Main entry point — wires interceptor, registry, aggregator, transport; returns EcoAPIHandle - _types.py # All types: RawEvent, MetricEntry, WindowSummary, ProviderDef, EcoAPIConfig, TransportMode - _provider_registry.py # ProviderRegistry — 21+ built-in rules, wildcard host matching, custom provider priority + _init.py # Main entry point — wires interceptor, registry, aggregator, transport; returns RecostHandle + _types.py # All types: RawEvent, MetricEntry, WindowSummary, ProviderDef, RecostConfig, FlushStatus, TransportMode + _provider_registry.py # ProviderRegistry — 34 built-in rules (14 providers), wildcard host matching, custom provider priority _interceptor.py # Patches urllib3.HTTPConnectionPool.urlopen, httpx.Client.send, httpx.AsyncClient.send, aiohttp.ClientSession._request _aggregator.py # Time-windowed bucketing by provider+endpoint+method, p50/p95 percentiles, cost aggregation _transport.py # Cloud mode (HTTPS POST with retry) + local mode (WebSocket with reconnect on background thread) frameworks/ __init__.py - fastapi.py # EcoAPIMiddleware — ASGI middleware for FastAPI/Starlette - flask.py # EcoAPI — Flask extension with init_app() pattern + fastapi.py # RecostMiddleware — ASGI middleware for FastAPI/Starlette + flask.py # RecostExtension — Flask extension with init_app() pattern (ReCost is a deprecated alias) tests/ conftest.py # Fixtures — cleanup interceptor after each test test_scaffold.py # Smoke tests for public API exports test_types.py # MetricEntry & WindowSummary serialization (camelCase conversion) - test_provider_registry.py # All 21 built-in providers, wildcards, Twilio refinement, custom priority + test_provider_registry.py # All 34 built-in provider rules, wildcards, Twilio refinement, custom priority test_aggregator.py # Flush, grouping, percentiles, error counting, byte sums, cost, null handling test_interceptor.py # urllib3/requests, httpx sync+async, aiohttp, lifecycle, double-count prevention test_transport.py # Mode detection, HTTP server mocking, retry logic @@ -63,7 +63,7 @@ mypy recost/ # Type check (strict mode) ## Provider Registry -21+ built-in rules covering: +34 built-in rules across 14 providers: - **AI**: OpenAI (6 endpoint rules), Anthropic (2 rules) - **Payments**: Stripe (5 rules) - **Communication**: Twilio (1 rule with dynamic SMS/voice refinement), SendGrid (2 rules) diff --git a/README.md b/README.md index e4df87c..ca05818 100644 --- a/README.md +++ b/README.md @@ -81,19 +81,23 @@ app.add_middleware(RecostMiddleware, api_key="...", project_id="...") ```python from flask import Flask -from recost.frameworks.flask import ReCost +from recost.frameworks.flask import RecostExtension app = Flask(__name__) -ReCost(app, api_key="...", project_id="...") +RecostExtension(app, api_key="...", project_id="...") ``` Or using the `init_app` pattern: ```python -recost = ReCost() -recost.init_app(app, api_key="...", project_id="...") +ext = RecostExtension() +ext.init_app(app, api_key="...", project_id="...") ``` +> **Note:** the old class name `ReCost` is still importable as a deprecated +> alias and will continue to work for one release with a `DeprecationWarning`. +> Migrate to `RecostExtension`. + ## Configuration All fields are optional. Pass them as keyword arguments or via a `RecostConfig` instance. @@ -103,8 +107,10 @@ All fields are optional. Pass them as keyword arguments or via a `RecostConfig` | `api_key` | `str` | — | Recost API key (`rc-...`). If omitted, runs in local mode. | | `project_id` | `str` | — | Recost project ID. Required in cloud mode. | | `environment` | `str` | `"development"` | Environment tag attached to all telemetry. | -| `flush_interval` | `float` | `30.0` | Seconds between automatic flushes. | +| `flush_interval_ms` | `int` | `30000` | Milliseconds between automatic aggregator flushes. | +| `flush_interval` | `float` | — | **Deprecated.** Legacy seconds-based flush interval. If set, takes precedence over `flush_interval_ms` and emits a `DeprecationWarning`. Will be removed in a future release. | | `max_batch_size` | `int` | `100` | Early-flush threshold (number of events). | +| `max_buckets` | `int` | `2000` | Maximum unique (provider, endpoint, method) triplets per window. Crossing this triggers an early flush. | | `local_port` | `int` | `9847` | WebSocket port for the VS Code extension. | | `debug` | `bool` | `False` | Log telemetry activity to stderr. | | `enabled` | `bool` | `True` | Master kill switch — set `False` to disable entirely. | @@ -112,7 +118,8 @@ All fields are optional. Pass them as keyword arguments or via a `RecostConfig` | `exclude_patterns` | `list[str]` | `[]` | URL substrings — matching requests are silently dropped. | | `base_url` | `str` | `"https://api.recost.dev"` | Override for self-hosted deployments. | | `max_retries` | `int` | `3` | Retry attempts for failed cloud flushes. | -| `on_error` | `Callable` | — | Called on internal SDK errors. | +| `shutdown_flush_timeout_ms` | `int` | `3000` | How long `dispose()` waits for the final flush to complete before closing the transport. | +| `on_error` | `Callable[[Exception], None]` | — | Called on internal SDK errors. | ### Custom providers diff --git a/recost/__init__.py b/recost/__init__.py index 6351c6e..782f201 100644 --- a/recost/__init__.py +++ b/recost/__init__.py @@ -1,8 +1,8 @@ """ -recost — Python SDK for ReCost. +recost — Python SDK for Recost. Tracks outbound HTTP API calls and reports cost, latency, and usage patterns -to the ReCost dashboard or your local VS Code extension. +to the Recost dashboard or your local VS Code extension. """ from ._types import ( diff --git a/recost/_init.py b/recost/_init.py index 5578adf..4567250 100644 --- a/recost/_init.py +++ b/recost/_init.py @@ -80,7 +80,7 @@ def dispose(self) -> None: def init(config: Optional[RecostConfig] = None) -> RecostHandle: """ - Initialize the ReCost SDK. + Initialize the Recost SDK. - Patches urllib3, httpx, and aiohttp. - Starts a flush interval that sends aggregated telemetry. diff --git a/recost/_transport.py b/recost/_transport.py index f35c4be..2a02e50 100644 --- a/recost/_transport.py +++ b/recost/_transport.py @@ -1,7 +1,7 @@ """ Transport — delivers WindowSummary payloads to either: - api.recost.dev (cloud mode) via HTTPS POST with exponential-backoff retry, or - - the ReCost VS Code extension (local mode) via WebSocket on localhost. + - the Recost VS Code extension (local mode) via WebSocket on localhost. Uses urllib.request (stdlib) for cloud transport to avoid self-instrumentation (the interceptor patches urllib3, not urllib.request). diff --git a/recost/frameworks/fastapi.py b/recost/frameworks/fastapi.py index ac72549..2ad092a 100644 --- a/recost/frameworks/fastapi.py +++ b/recost/frameworks/fastapi.py @@ -22,7 +22,7 @@ from starlette.responses import Response class RecostMiddleware(BaseHTTPMiddleware): - """ASGI middleware that initializes ReCost telemetry.""" + """ASGI middleware that initializes Recost telemetry.""" def __init__(self, app: Any, config: Optional[RecostConfig] = None, **kwargs: Any) -> None: super().__init__(app) diff --git a/recost/frameworks/flask.py b/recost/frameworks/flask.py index 16b7259..4403763 100644 --- a/recost/frameworks/flask.py +++ b/recost/frameworks/flask.py @@ -3,14 +3,15 @@ Usage: from flask import Flask - from recost.frameworks.flask import ReCost + from recost.frameworks.flask import RecostExtension app = Flask(__name__) - eco = ReCost(app, api_key="...", project_id="...") + ext = RecostExtension(app, api_key="...", project_id="...") """ from __future__ import annotations +import warnings from typing import Any, Optional from .._init import init @@ -19,25 +20,63 @@ try: from flask import Flask - class ReCost: - """Flask extension that initializes ReCost telemetry.""" + class RecostExtension: + """Flask extension that initializes Recost telemetry.""" - def __init__(self, app: Optional[Flask] = None, config: Optional[RecostConfig] = None, **kwargs: Any) -> None: + def __init__( + self, + app: Optional[Flask] = None, + config: Optional[RecostConfig] = None, + **kwargs: Any, + ) -> None: self._handle = None if app is not None: self.init_app(app, config, **kwargs) - def init_app(self, app: Flask, config: Optional[RecostConfig] = None, **kwargs: Any) -> None: + def init_app( + self, + app: Flask, + config: Optional[RecostConfig] = None, + **kwargs: Any, + ) -> None: if config is None: config = RecostConfig(**kwargs) self._handle = init(config) + class ReCost(RecostExtension): + """Deprecated alias for :class:`RecostExtension`. + + Will be removed in a future release. Switch to + ``from recost.frameworks.flask import RecostExtension``. + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + warnings.warn( + "recost.frameworks.flask.ReCost is deprecated and will be " + "removed in a future release; use RecostExtension instead.", + DeprecationWarning, + stacklevel=2, + ) + super().__init__(*args, **kwargs) + except ImportError: - class ReCost: # type: ignore[no-redef] + class RecostExtension: # type: ignore[no-redef] """Stub — install 'flask' to use: pip install recost[flask]""" def __init__(self, *args: Any, **kwargs: Any) -> None: raise ImportError( - "flask is required for ReCost extension. " + "flask is required for the Recost extension. " "Install it with: pip install recost[flask]" ) + + class ReCost(RecostExtension): # type: ignore[no-redef] + """Stub deprecated alias — install 'flask' to use.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + warnings.warn( + "recost.frameworks.flask.ReCost is deprecated; use " + "RecostExtension instead.", + DeprecationWarning, + stacklevel=2, + ) + super().__init__(*args, **kwargs) diff --git a/tests/test_flask.py b/tests/test_flask.py index 689e8ac..339d53d 100644 --- a/tests/test_flask.py +++ b/tests/test_flask.py @@ -2,20 +2,20 @@ Tests for recost/frameworks/flask.py """ -import pytest +import warnings from recost._interceptor import is_installed, uninstall from recost._types import RecostConfig -class TestFlaskExtension: +class TestRecostExtension: def test_extension_initializes_interceptor(self): try: from flask import Flask - from recost.frameworks.flask import ReCost + from recost.frameworks.flask import RecostExtension app = Flask(__name__) - eco = ReCost(app, config=RecostConfig(enabled=True)) + RecostExtension(app, config=RecostConfig(enabled=True)) assert is_installed() finally: uninstall() @@ -23,22 +23,50 @@ def test_extension_initializes_interceptor(self): def test_extension_init_app_pattern(self): try: from flask import Flask - from recost.frameworks.flask import ReCost + from recost.frameworks.flask import RecostExtension - eco = ReCost() + ext = RecostExtension() app = Flask(__name__) - eco.init_app(app, config=RecostConfig(enabled=True)) + ext.init_app(app, config=RecostConfig(enabled=True)) assert is_installed() finally: uninstall() def test_extension_accepts_kwargs(self): + try: + from flask import Flask + from recost.frameworks.flask import RecostExtension + + app = Flask(__name__) + RecostExtension(app, enabled=True, debug=False) + assert is_installed() + finally: + uninstall() + + +class TestReCostDeprecationAlias: + """The old `ReCost` name must keep working for one release but emit a + DeprecationWarning so users migrate to `RecostExtension`.""" + + def test_old_name_still_constructs(self): try: from flask import Flask from recost.frameworks.flask import ReCost app = Flask(__name__) - eco = ReCost(app, enabled=True, debug=False) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + ReCost(app, config=RecostConfig(enabled=True)) assert is_installed() + deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)] + assert deprecations, "expected a DeprecationWarning on ReCost(...)" + assert "RecostExtension" in str(deprecations[0].message) finally: uninstall() + + def test_old_name_is_subclass_or_alias_of_new(self): + """Importing the old name must yield the same class as the new name + so isinstance checks and existing type annotations keep working.""" + from recost.frameworks.flask import ReCost, RecostExtension + + assert ReCost is RecostExtension or issubclass(ReCost, RecostExtension) diff --git a/tests/test_provider_registry.py b/tests/test_provider_registry.py index bb75bf7..b9077f5 100644 --- a/tests/test_provider_registry.py +++ b/tests/test_provider_registry.py @@ -11,7 +11,7 @@ class TestBuiltinProviders: - """Tests for all 21 built-in provider rules.""" + """Tests for all 34 built-in provider rules (14 unique providers).""" def setup_method(self): self.registry = ProviderRegistry() @@ -370,3 +370,18 @@ def test_pinecone_specific_before_catch_all(self): # Need to import ProviderDef for custom provider tests from recost import ProviderDef # noqa: E402 + + +# --------------------------------------------------------------------------- +# Built-in provider count — pins the published claim +# --------------------------------------------------------------------------- + +def test_builtin_providers_count_is_pinned(): + """If you add or remove a built-in provider rule, update this assertion + AND update the provider-count claims in README.md and CLAUDE.md.""" + from recost._provider_registry import BUILTIN_PROVIDERS + + assert len(BUILTIN_PROVIDERS) == 34, ( + f"BUILTIN_PROVIDERS has {len(BUILTIN_PROVIDERS)} rules; " + f"docs claim 34. Update docs and this assertion together." + )