Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,11 +116,14 @@ All fields are optional. Pass them as keyword arguments or via a `RecostConfig`
| `enabled` | `bool` | `True` | Master kill switch — set `False` to disable entirely. |
| `custom_providers` | `list[ProviderDef]` | `[]` | Extra provider rules with higher priority than built-ins. |
| `exclude_patterns` | `list[str]` | `[]` | URL substrings — matching requests are silently dropped. |
| `exclude_hosts` | `list[str]` | `[]` | Exact host names to exclude (event.host match). Use for unambiguous host-level exclusion without substring false-positives. |
| `base_url` | `str` | `"https://api.recost.dev"` | Override for self-hosted deployments. |
| `max_retries` | `int` | `3` | Retry attempts for failed cloud flushes. |
| `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. |

> **Note on exclusions:** `exclude_patterns` performs substring matching against both `event.url` and `event.host`; patterns containing `*` raise `ValueError` at init time (substring matching is not glob). For unambiguous host-level exclusion without substring false-positives (e.g., excluding `api.example.com` without also dropping `myapi.example.com`), use `exclude_hosts` instead. Both are applied additively — events matching either are dropped before reaching the aggregator.

### Custom providers

```python
Expand Down
69 changes: 60 additions & 9 deletions recost/_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import threading
import warnings
from typing import Callable, List, Optional
from urllib.parse import urlparse

from ._aggregator import Aggregator
from ._interceptor import install, uninstall
Expand Down Expand Up @@ -185,6 +186,14 @@ def init(config: Optional[RecostConfig] = None) -> RecostHandle:
f"See https://recost.dev/docs/api-keys."
)

for pat in config.exclude_patterns:
if "*" in pat:
raise ValueError(
f"Recost: exclude_patterns is substring match, not "
f"glob. Got {pat!r} with '*'. Use exclude_hosts for "
f"exact host match, or remove the asterisk."
)

# Resolve flush interval: prefer the new ms-based field, but if a caller
# still passes the legacy seconds-based flush_interval, honor it with a
# deprecation warning so existing code keeps working until they migrate.
Expand Down Expand Up @@ -227,13 +236,23 @@ def _defer(ms: int) -> None:

transport.set_defer_callback(_defer)

# Build the set of URL substrings to exclude from tracking.
# Build exclude sets: exact-host short-circuit + substring fallback.
exclude_patterns = list(config.exclude_patterns)
exclude_hosts_set = set(config.exclude_hosts)
if config.api_key:
exclude_patterns.append(config.base_url.rstrip("/"))
base_host = urlparse(config.base_url).hostname or ""
if base_host:
exclude_hosts_set.add(base_host)
# When base_url points at a loopback, add the other loopback
# form too so 127.0.0.1 / localhost are interchangeable.
if base_host in {"localhost", "127.0.0.1"}:
exclude_hosts_set.add("localhost")
exclude_hosts_set.add("127.0.0.1")
else:
exclude_patterns.append(f"127.0.0.1:{config.local_port}")
exclude_patterns.append(f"localhost:{config.local_port}")
exclude_hosts_set.add("localhost")
exclude_hosts_set.add("127.0.0.1")
# local_port is still substring-matched (port-specific)
exclude_patterns.append(f":{config.local_port}")

# PID backstop cells — populated after `handle` is constructed below.
# The on_event closure reads these on every event so a forked child
Expand All @@ -242,17 +261,24 @@ def _defer(ms: int) -> None:
_handle_ref: List[Optional["RecostHandle"]] = [None]
_pid_warned: List[bool] = [False]

def flush_and_send() -> None:
def flush_and_send() -> bool:
"""Returns True iff transport.send was actually invoked.

Returning False (empty window — nothing to send) lets the
caller distinguish a no-op from a successful network flush,
which matters for backoff bookkeeping in _timer_loop.
"""
summary = aggregator.flush()
if summary is None:
return
return False
if debug:
print(
f"[recost] flush: {len(summary.metrics)} metric group(s), "
f"window {summary.window_start} → {summary.window_end}",
file=sys.stderr,
)
transport.send(summary)
return True

def on_event(event: RawEvent) -> None:
# PID backstop: if the at-fork hook didn't fire on this platform,
Expand All @@ -272,7 +298,9 @@ def on_event(event: RawEvent) -> None:
except Exception:
return # never let SDK errors break user code

# Drop excluded URLs
# Drop excluded events — exact host match short-circuits first
if event.host in exclude_hosts_set:
return
for pattern in exclude_patterns:
if pattern in event.url or pattern in event.host:
return
Expand Down Expand Up @@ -320,17 +348,40 @@ def on_event(event: RawEvent) -> None:
stop_event = threading.Event()

def _timer_loop() -> None:
consecutive_failures = 0
last_backoff_announced_ms = 0
while not stop_event.is_set():
# Consume any pending 429 deferral before the next sleep.
# Consume any pending deferral (429 or flush-backoff) before next sleep.
with _deferral_lock:
extra_ms = _deferral_ms[0]
_deferral_ms[0] = 0
wait = flush_interval_seconds + (extra_ms / 1000.0)
if stop_event.wait(timeout=wait):
return
try:
flush_and_send()
sent = flush_and_send()
# Only a real successful send clears the failure
# streak — an empty window is a no-op, not a recovery.
if sent and consecutive_failures > 0:
consecutive_failures = 0
last_backoff_announced_ms = 0
except Exception as err:
consecutive_failures += 1
if consecutive_failures >= 5:
backoff_ms = min(
1000 * (2 ** (consecutive_failures - 5)),
300_000,
)
_defer(backoff_ms)
if backoff_ms > last_backoff_announced_ms:
last_backoff_announced_ms = backoff_ms
from ._types import RecostError
if config.on_error is not None:
config.on_error(RecostError(
f"recost: flush failing repeatedly "
f"({consecutive_failures} consecutive); "
f"backing off {backoff_ms}ms"
))
if config.on_error:
config.on_error(err)
elif debug:
Expand Down
5 changes: 5 additions & 0 deletions recost/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,11 @@ class RecostConfig:
enabled: bool = True
custom_providers: List[ProviderDef] = field(default_factory=list)
exclude_patterns: List[str] = field(default_factory=list)
exclude_hosts: List[str] = field(default_factory=list)
"""Exact host names to exclude from telemetry (event.host == h).
Distinct from exclude_patterns (substring-match against event.url
and event.host). Use this for unambiguous host-level exclusion
that won't false-positive on substring overlap."""
base_url: str = "https://api.recost.dev"
max_retries: int = 3
shutdown_flush_timeout_ms: int = 3_000
Expand Down
3 changes: 3 additions & 0 deletions recost/frameworks/flask.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ def init_app(
config: Optional[RecostConfig] = None,
**kwargs: Any,
) -> None:
if self._handle is not None:
self._handle.dispose()
self._handle = None
if config is None:
config = RecostConfig(**kwargs)
self._handle = init(config)
Expand Down
46 changes: 46 additions & 0 deletions tests/test_flask.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,49 @@ def test_flask_extension_raises_clear_error_without_flask(monkeypatch) -> None:
finally:
monkeypatch.undo()
importlib.reload(flask_mod)


class TestInitAppDisposePrevious:
"""init_app() must dispose self._handle before reassigning so the
extension instance never carries a stale handle reference."""

def test_init_app_disposes_previous_handle(self):
from flask import Flask
from recost.frameworks.flask import RecostExtension

ext = RecostExtension()
try:
app1 = Flask("app1")
ext.init_app(app1, config=RecostConfig(enabled=True))
first_handle = ext._handle
assert first_handle is not None
assert first_handle._disposed is False

app2 = Flask("app2")
ext.init_app(app2, config=RecostConfig(enabled=True))
second_handle = ext._handle

assert first_handle._disposed is True, (
"previous handle was not disposed when init_app ran again"
)
assert second_handle is not first_handle
assert second_handle is not None
assert second_handle._disposed is False
finally:
uninstall()

def test_init_app_idempotent_when_no_previous_handle(self):
"""First-time init_app on a fresh extension must work — the
dispose-previous branch is skipped when self._handle is None."""
from flask import Flask
from recost.frameworks.flask import RecostExtension

ext = RecostExtension() # no app, self._handle stays None
try:
app = Flask(__name__)
ext.init_app(app, config=RecostConfig(enabled=True))
assert ext._handle is not None
assert ext._handle._disposed is False
assert is_installed()
finally:
uninstall()
Loading
Loading