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
292 changes: 150 additions & 142 deletions recost/_init.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -51,32 +51,39 @@ def dispose(self) -> None:
the final flush settles or its timeout elapses, so an in-flight
cloud POST is not cut off mid-request.
"""
if self._disposed:
return
self._disposed = True
with _init_lock:
if self._disposed:
return
self._disposed = True

self._timer_stop.set()
if self._timer_thread is not None:
self._timer_thread.join(timeout=5.0)
self._timer_stop.set()
if self._timer_thread is not None:
self._timer_thread.join(timeout=5.0)

if self._final_flush is not None:
flush_thread = threading.Thread(target=self._final_flush, daemon=True)
flush_thread.start()
flush_thread.join(timeout=self._shutdown_flush_timeout_ms / 1000.0)
if self._final_flush is not None:
flush_thread = threading.Thread(target=self._final_flush, daemon=True)
flush_thread.start()
flush_thread.join(timeout=self._shutdown_flush_timeout_ms / 1000.0)

uninstall()
uninstall()

if self._transport is not None:
self._transport.dispose()
if self._transport is not None:
self._transport.dispose()

global _handle
if _handle is self:
_handle = None
global _handle
if _handle is self:
_handle = None


# Module-level handle so a second init() call disposes the first.
_handle: Optional[RecostHandle] = None

# Guards init() and dispose() so the _handle global cannot become
# inconsistent under concurrent callers. RLock so a wrapping caller
# (e.g. dispose() running on the timer thread which itself owns the
# init lock) does not deadlock. See issue #4.
_init_lock: threading.RLock = threading.RLock()


def init(config: Optional[RecostConfig] = None) -> RecostHandle:
"""
Expand All@@ -87,112 +94,127 @@ def init(config: Optional[RecostConfig] = None) -> RecostHandle:
- Returns a handle with a dispose() method for explicit cleanup.
"""
global _handle
if _handle is not None:
_handle.dispose()

config = config or RecostConfig()

# 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.
if config.flush_interval is not None:
warnings.warn(
"flush_interval is deprecated, use flush_interval_ms instead",
DeprecationWarning,
stacklevel=2,
)
flush_interval_seconds = float(config.flush_interval)
else:
flush_interval_seconds = config.flush_interval_ms / 1000.0

if not config.enabled:
stop_event = threading.Event()
stop_event.set()
noop = RecostHandle(timer_stop=stop_event, timer_thread=None, transport=None)
_handle = noop
return noop

registry = ProviderRegistry(config.custom_providers or None)
aggregator = Aggregator(
project_id=config.project_id or "",
environment=config.environment,
sdk_version="0.1.0",
max_buckets=config.max_buckets,
)
transport = Transport(config)
debug = config.debug
max_batch_size = config.max_batch_size

# Build the set of URL substrings to exclude from tracking.
exclude_patterns = list(config.exclude_patterns)
if config.api_key:
exclude_patterns.append(config.base_url.rstrip("/"))
else:
exclude_patterns.append(f"127.0.0.1:{config.local_port}")
exclude_patterns.append(f"localhost:{config.local_port}")

def flush_and_send() -> None:
summary = aggregator.flush()
if summary is None:
return
if debug:
print(
f"[recost] flush: {len(summary.metrics)} metric group(s), "
f"window {summary.window_start} → {summary.window_end}",
file=sys.stderr,
with _init_lock:
if _handle is not None:
_handle.dispose()

config = config or RecostConfig()

# 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.
if config.flush_interval is not None:
warnings.warn(
"flush_interval is deprecated, use flush_interval_ms instead",
DeprecationWarning,
stacklevel=2,
)
transport.send(summary)

def on_event(event: RawEvent) -> None:
# Drop excluded URLs
for pattern in exclude_patterns:
if pattern in event.url or pattern in event.host:
flush_interval_seconds = float(config.flush_interval)
else:
flush_interval_seconds = config.flush_interval_ms / 1000.0

if not config.enabled:
stop_event = threading.Event()
stop_event.set()
noop = RecostHandle(timer_stop=stop_event, timer_thread=None, transport=None)
_handle = noop
return noop

registry = ProviderRegistry(config.custom_providers or None)
aggregator = Aggregator(
project_id=config.project_id or "",
environment=config.environment,
sdk_version="0.1.0",
max_buckets=config.max_buckets,
)
transport = Transport(config)
debug = config.debug
max_batch_size = config.max_batch_size

# Build the set of URL substrings to exclude from tracking.
exclude_patterns = list(config.exclude_patterns)
if config.api_key:
exclude_patterns.append(config.base_url.rstrip("/"))
else:
exclude_patterns.append(f"127.0.0.1:{config.local_port}")
exclude_patterns.append(f"localhost:{config.local_port}")

def flush_and_send() -> None:
summary = aggregator.flush()
if summary is None:
return
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)

def on_event(event: RawEvent) -> None:
# Drop excluded URLs
for pattern in exclude_patterns:
if pattern in event.url or pattern in event.host:
return

# Enrich with provider/endpoint from the registry
match = registry.match(event.url)
if match is not None:
event.provider = match.provider
event.endpoint_category = match.endpoint_category

if debug:
print(
f"[recost] captured {event.method} {event.url} "
f"{event.status_code} ({event.latency_ms}ms)",
file=sys.stderr,
)

# If this event would push us past the bucket cap, flush the current
# window first so it's preserved, then ingest into a fresh window.
if aggregator.would_overflow(event):
try:
flush_and_send()
except Exception as err:
if config.on_error:
config.on_error(err)
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

cost = match.cost_per_request_cents if match is not None else 0.0
aggregator.ingest(event, cost)

# Trigger an early flush if the batch size threshold is reached
if aggregator.size >= max_batch_size:
try:
flush_and_send()
except Exception as err:
if config.on_error:
config.on_error(err)
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

install(on_event)

# Flush timer using a background thread with Event-based stopping
stop_event = threading.Event()

# Enrich with provider/endpoint from the registry
match = registry.match(event.url)
if match is not None:
event.provider = match.provider
event.endpoint_category = match.endpoint_category

if debug:
print(
f"[recost] captured {event.method} {event.url} "
f"{event.status_code} ({event.latency_ms}ms)",
file=sys.stderr,
)

# If this event would push us past the bucket cap, flush the current
# window first so it's preserved, then ingest into a fresh window.
if aggregator.would_overflow(event):
try:
flush_and_send()
except Exception as err:
if config.on_error:
config.on_error(err)
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

cost = match.cost_per_request_cents if match is not None else 0.0
aggregator.ingest(event, cost)

# Trigger an early flush if the batch size threshold is reached
if aggregator.size >= max_batch_size:
try:
flush_and_send()
except Exception as err:
if config.on_error:
config.on_error(err)
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

install(on_event)

# Flush timer using a background thread with Event-based stopping
stop_event = threading.Event()

def _timer_loop() -> None:
while not stop_event.wait(timeout=flush_interval_seconds):
def _timer_loop() -> None:
while not stop_event.wait(timeout=flush_interval_seconds):
try:
flush_and_send()
except Exception as err:
if config.on_error:
config.on_error(err)
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

timer_thread = threading.Thread(target=_timer_loop, daemon=True)
timer_thread.start()

def _final_flush() -> None:
# Errors during the final flush are logged / forwarded the same way
# as a normal tick — we never want dispose() to surface them.
try:
flush_and_send()
except Exception as err:
Expand All@@ -201,26 +223,12 @@ def _timer_loop() -> None:
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

timer_thread = threading.Thread(target=_timer_loop, daemon=True)
timer_thread.start()

def _final_flush() -> None:
# Errors during the final flush are logged / forwarded the same way
# as a normal tick — we never want dispose() to surface them.
try:
flush_and_send()
except Exception as err:
if config.on_error:
config.on_error(err)
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

handle = RecostHandle(
timer_stop=stop_event,
timer_thread=timer_thread,
transport=transport,
final_flush=_final_flush,
shutdown_flush_timeout_ms=config.shutdown_flush_timeout_ms,
)
_handle = handle
return handle
handle = RecostHandle(
timer_stop=stop_event,
timer_thread=timer_thread,
transport=transport,
final_flush=_final_flush,
shutdown_flush_timeout_ms=config.shutdown_flush_timeout_ms,
)
_handle = handle
return handle
40 changes: 25 additions & 15 deletions recost/_interceptor.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,7 @@
from __future__ import annotations

import contextvars
import threading
import time
from datetime import datetime, timezone
from typing import Callable, Optional
Expand All@@ -30,6 +31,12 @@
_installed: bool = False
_callback: Optional[EventCallback] = None

# Guards install / uninstall / is_installed so the patched-method state
# cannot drift away from the _installed flag under concurrent callers.
# RLock so the same thread can re-enter (e.g. from a callback that
# triggers reinstall). See issue #4.
_install_lock: threading.RLock = threading.RLock()

# Original function references — restored on uninstall
_original_urllib3_urlopen = None
_original_httpx_send = None
Expand DownExpand Up@@ -372,29 +379,32 @@ def _unpatch_aiohttp() -> None:
def install(callback: EventCallback) -> None:
"""Install patches on urllib3, httpx, and aiohttp. No-op if already installed."""
global _installed, _callback
if _installed:
return
with _install_lock:
if _installed:
return

_callback = callback
_patch_urllib3()
_patch_httpx()
_patch_aiohttp()
_installed = True
_callback = callback
_patch_urllib3()
_patch_httpx()
_patch_aiohttp()
_installed = True


def uninstall() -> None:
"""Restore all patched functions to their originals. No-op if not installed."""
global _installed, _callback
if not _installed:
return
with _install_lock:
if not _installed:
return

_unpatch_urllib3()
_unpatch_httpx()
_unpatch_aiohttp()
_callback = None
_installed = False
_unpatch_urllib3()
_unpatch_httpx()
_unpatch_aiohttp()
_callback = None
_installed = False


def is_installed() -> bool:
"""Returns True if patches are currently active."""
return _installed
with _install_lock:
return _installed
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
292 changes: 150 additions & 142 deletions recost/_init.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -51,32 +51,39 @@ def dispose(self) -> None:
the final flush settles or its timeout elapses, so an in-flight
cloud POST is not cut off mid-request.
"""
if self._disposed:
return
self._disposed = True
with _init_lock:
if self._disposed:
return
self._disposed = True

self._timer_stop.set()
if self._timer_thread is not None:
self._timer_thread.join(timeout=5.0)
self._timer_stop.set()
if self._timer_thread is not None:
self._timer_thread.join(timeout=5.0)

if self._final_flush is not None:
flush_thread = threading.Thread(target=self._final_flush, daemon=True)
flush_thread.start()
flush_thread.join(timeout=self._shutdown_flush_timeout_ms / 1000.0)
if self._final_flush is not None:
flush_thread = threading.Thread(target=self._final_flush, daemon=True)
flush_thread.start()
flush_thread.join(timeout=self._shutdown_flush_timeout_ms / 1000.0)

uninstall()
uninstall()

if self._transport is not None:
self._transport.dispose()
if self._transport is not None:
self._transport.dispose()

global _handle
if _handle is self:
_handle = None
global _handle
if _handle is self:
_handle = None


# Module-level handle so a second init() call disposes the first.
_handle: Optional[RecostHandle] = None

# Guards init() and dispose() so the _handle global cannot become
# inconsistent under concurrent callers. RLock so a wrapping caller
# (e.g. dispose() running on the timer thread which itself owns the
# init lock) does not deadlock. See issue #4.
_init_lock: threading.RLock = threading.RLock()


def init(config: Optional[RecostConfig] = None) -> RecostHandle:
"""
Expand All@@ -87,112 +94,127 @@ def init(config: Optional[RecostConfig] = None) -> RecostHandle:
- Returns a handle with a dispose() method for explicit cleanup.
"""
global _handle
if _handle is not None:
_handle.dispose()

config = config or RecostConfig()

# 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.
if config.flush_interval is not None:
warnings.warn(
"flush_interval is deprecated, use flush_interval_ms instead",
DeprecationWarning,
stacklevel=2,
)
flush_interval_seconds = float(config.flush_interval)
else:
flush_interval_seconds = config.flush_interval_ms / 1000.0

if not config.enabled:
stop_event = threading.Event()
stop_event.set()
noop = RecostHandle(timer_stop=stop_event, timer_thread=None, transport=None)
_handle = noop
return noop

registry = ProviderRegistry(config.custom_providers or None)
aggregator = Aggregator(
project_id=config.project_id or "",
environment=config.environment,
sdk_version="0.1.0",
max_buckets=config.max_buckets,
)
transport = Transport(config)
debug = config.debug
max_batch_size = config.max_batch_size

# Build the set of URL substrings to exclude from tracking.
exclude_patterns = list(config.exclude_patterns)
if config.api_key:
exclude_patterns.append(config.base_url.rstrip("/"))
else:
exclude_patterns.append(f"127.0.0.1:{config.local_port}")
exclude_patterns.append(f"localhost:{config.local_port}")

def flush_and_send() -> None:
summary = aggregator.flush()
if summary is None:
return
if debug:
print(
f"[recost] flush: {len(summary.metrics)} metric group(s), "
f"window {summary.window_start} → {summary.window_end}",
file=sys.stderr,
with _init_lock:
if _handle is not None:
_handle.dispose()

config = config or RecostConfig()

# 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.
if config.flush_interval is not None:
warnings.warn(
"flush_interval is deprecated, use flush_interval_ms instead",
DeprecationWarning,
stacklevel=2,
)
transport.send(summary)

def on_event(event: RawEvent) -> None:
# Drop excluded URLs
for pattern in exclude_patterns:
if pattern in event.url or pattern in event.host:
flush_interval_seconds = float(config.flush_interval)
else:
flush_interval_seconds = config.flush_interval_ms / 1000.0

if not config.enabled:
stop_event = threading.Event()
stop_event.set()
noop = RecostHandle(timer_stop=stop_event, timer_thread=None, transport=None)
_handle = noop
return noop

registry = ProviderRegistry(config.custom_providers or None)
aggregator = Aggregator(
project_id=config.project_id or "",
environment=config.environment,
sdk_version="0.1.0",
max_buckets=config.max_buckets,
)
transport = Transport(config)
debug = config.debug
max_batch_size = config.max_batch_size

# Build the set of URL substrings to exclude from tracking.
exclude_patterns = list(config.exclude_patterns)
if config.api_key:
exclude_patterns.append(config.base_url.rstrip("/"))
else:
exclude_patterns.append(f"127.0.0.1:{config.local_port}")
exclude_patterns.append(f"localhost:{config.local_port}")

def flush_and_send() -> None:
summary = aggregator.flush()
if summary is None:
return
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)

def on_event(event: RawEvent) -> None:
# Drop excluded URLs
for pattern in exclude_patterns:
if pattern in event.url or pattern in event.host:
return

# Enrich with provider/endpoint from the registry
match = registry.match(event.url)
if match is not None:
event.provider = match.provider
event.endpoint_category = match.endpoint_category

if debug:
print(
f"[recost] captured {event.method} {event.url} "
f"{event.status_code} ({event.latency_ms}ms)",
file=sys.stderr,
)

# If this event would push us past the bucket cap, flush the current
# window first so it's preserved, then ingest into a fresh window.
if aggregator.would_overflow(event):
try:
flush_and_send()
except Exception as err:
if config.on_error:
config.on_error(err)
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

cost = match.cost_per_request_cents if match is not None else 0.0
aggregator.ingest(event, cost)

# Trigger an early flush if the batch size threshold is reached
if aggregator.size >= max_batch_size:
try:
flush_and_send()
except Exception as err:
if config.on_error:
config.on_error(err)
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

install(on_event)

# Flush timer using a background thread with Event-based stopping
stop_event = threading.Event()

# Enrich with provider/endpoint from the registry
match = registry.match(event.url)
if match is not None:
event.provider = match.provider
event.endpoint_category = match.endpoint_category

if debug:
print(
f"[recost] captured {event.method} {event.url} "
f"{event.status_code} ({event.latency_ms}ms)",
file=sys.stderr,
)

# If this event would push us past the bucket cap, flush the current
# window first so it's preserved, then ingest into a fresh window.
if aggregator.would_overflow(event):
try:
flush_and_send()
except Exception as err:
if config.on_error:
config.on_error(err)
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

cost = match.cost_per_request_cents if match is not None else 0.0
aggregator.ingest(event, cost)

# Trigger an early flush if the batch size threshold is reached
if aggregator.size >= max_batch_size:
try:
flush_and_send()
except Exception as err:
if config.on_error:
config.on_error(err)
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

install(on_event)

# Flush timer using a background thread with Event-based stopping
stop_event = threading.Event()

def _timer_loop() -> None:
while not stop_event.wait(timeout=flush_interval_seconds):
def _timer_loop() -> None:
while not stop_event.wait(timeout=flush_interval_seconds):
try:
flush_and_send()
except Exception as err:
if config.on_error:
config.on_error(err)
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

timer_thread = threading.Thread(target=_timer_loop, daemon=True)
timer_thread.start()

def _final_flush() -> None:
# Errors during the final flush are logged / forwarded the same way
# as a normal tick — we never want dispose() to surface them.
try:
flush_and_send()
except Exception as err:
Expand All@@ -201,26 +223,12 @@ def _timer_loop() -> None:
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

timer_thread = threading.Thread(target=_timer_loop, daemon=True)
timer_thread.start()

def _final_flush() -> None:
# Errors during the final flush are logged / forwarded the same way
# as a normal tick — we never want dispose() to surface them.
try:
flush_and_send()
except Exception as err:
if config.on_error:
config.on_error(err)
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

handle = RecostHandle(
timer_stop=stop_event,
timer_thread=timer_thread,
transport=transport,
final_flush=_final_flush,
shutdown_flush_timeout_ms=config.shutdown_flush_timeout_ms,
)
_handle = handle
return handle
handle = RecostHandle(
timer_stop=stop_event,
timer_thread=timer_thread,
transport=transport,
final_flush=_final_flush,
shutdown_flush_timeout_ms=config.shutdown_flush_timeout_ms,
)
_handle = handle
return handle
40 changes: 25 additions & 15 deletions recost/_interceptor.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,7 @@
from __future__ import annotations

import contextvars
import threading
import time
from datetime import datetime, timezone
from typing import Callable, Optional
Expand All@@ -30,6 +31,12 @@
_installed: bool = False
_callback: Optional[EventCallback] = None

# Guards install / uninstall / is_installed so the patched-method state
# cannot drift away from the _installed flag under concurrent callers.
# RLock so the same thread can re-enter (e.g. from a callback that
# triggers reinstall). See issue #4.
_install_lock: threading.RLock = threading.RLock()

# Original function references — restored on uninstall
_original_urllib3_urlopen = None
_original_httpx_send = None
Expand DownExpand Up@@ -372,29 +379,32 @@ def _unpatch_aiohttp() -> None:
def install(callback: EventCallback) -> None:
"""Install patches on urllib3, httpx, and aiohttp. No-op if already installed."""
global _installed, _callback
if _installed:
return
with _install_lock:
if _installed:
return

_callback = callback
_patch_urllib3()
_patch_httpx()
_patch_aiohttp()
_installed = True
_callback = callback
_patch_urllib3()
_patch_httpx()
_patch_aiohttp()
_installed = True


def uninstall() -> None:
"""Restore all patched functions to their originals. No-op if not installed."""
global _installed, _callback
if not _installed:
return
with _install_lock:
if not _installed:
return

_unpatch_urllib3()
_unpatch_httpx()
_unpatch_aiohttp()
_callback = None
_installed = False
_unpatch_urllib3()
_unpatch_httpx()
_unpatch_aiohttp()
_callback = None
_installed = False


def is_installed() -> bool:
"""Returns True if patches are currently active."""
return _installed
with _install_lock:
return _installed
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
292 changes: 150 additions & 142 deletions recost/_init.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -51,32 +51,39 @@ def dispose(self) -> None:
the final flush settles or its timeout elapses, so an in-flight
cloud POST is not cut off mid-request.
"""
if self._disposed:
return
self._disposed = True
with _init_lock:
if self._disposed:
return
self._disposed = True

self._timer_stop.set()
if self._timer_thread is not None:
self._timer_thread.join(timeout=5.0)
self._timer_stop.set()
if self._timer_thread is not None:
self._timer_thread.join(timeout=5.0)

if self._final_flush is not None:
flush_thread = threading.Thread(target=self._final_flush, daemon=True)
flush_thread.start()
flush_thread.join(timeout=self._shutdown_flush_timeout_ms / 1000.0)
if self._final_flush is not None:
flush_thread = threading.Thread(target=self._final_flush, daemon=True)
flush_thread.start()
flush_thread.join(timeout=self._shutdown_flush_timeout_ms / 1000.0)

uninstall()
uninstall()

if self._transport is not None:
self._transport.dispose()
if self._transport is not None:
self._transport.dispose()

global _handle
if _handle is self:
_handle = None
global _handle
if _handle is self:
_handle = None


# Module-level handle so a second init() call disposes the first.
_handle: Optional[RecostHandle] = None

# Guards init() and dispose() so the _handle global cannot become
# inconsistent under concurrent callers. RLock so a wrapping caller
# (e.g. dispose() running on the timer thread which itself owns the
# init lock) does not deadlock. See issue #4.
_init_lock: threading.RLock = threading.RLock()


def init(config: Optional[RecostConfig] = None) -> RecostHandle:
"""
Expand All@@ -87,112 +94,127 @@ def init(config: Optional[RecostConfig] = None) -> RecostHandle:
- Returns a handle with a dispose() method for explicit cleanup.
"""
global _handle
if _handle is not None:
_handle.dispose()

config = config or RecostConfig()

# 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.
if config.flush_interval is not None:
warnings.warn(
"flush_interval is deprecated, use flush_interval_ms instead",
DeprecationWarning,
stacklevel=2,
)
flush_interval_seconds = float(config.flush_interval)
else:
flush_interval_seconds = config.flush_interval_ms / 1000.0

if not config.enabled:
stop_event = threading.Event()
stop_event.set()
noop = RecostHandle(timer_stop=stop_event, timer_thread=None, transport=None)
_handle = noop
return noop

registry = ProviderRegistry(config.custom_providers or None)
aggregator = Aggregator(
project_id=config.project_id or "",
environment=config.environment,
sdk_version="0.1.0",
max_buckets=config.max_buckets,
)
transport = Transport(config)
debug = config.debug
max_batch_size = config.max_batch_size

# Build the set of URL substrings to exclude from tracking.
exclude_patterns = list(config.exclude_patterns)
if config.api_key:
exclude_patterns.append(config.base_url.rstrip("/"))
else:
exclude_patterns.append(f"127.0.0.1:{config.local_port}")
exclude_patterns.append(f"localhost:{config.local_port}")

def flush_and_send() -> None:
summary = aggregator.flush()
if summary is None:
return
if debug:
print(
f"[recost] flush: {len(summary.metrics)} metric group(s), "
f"window {summary.window_start} → {summary.window_end}",
file=sys.stderr,
with _init_lock:
if _handle is not None:
_handle.dispose()

config = config or RecostConfig()

# 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.
if config.flush_interval is not None:
warnings.warn(
"flush_interval is deprecated, use flush_interval_ms instead",
DeprecationWarning,
stacklevel=2,
)
transport.send(summary)

def on_event(event: RawEvent) -> None:
# Drop excluded URLs
for pattern in exclude_patterns:
if pattern in event.url or pattern in event.host:
flush_interval_seconds = float(config.flush_interval)
else:
flush_interval_seconds = config.flush_interval_ms / 1000.0

if not config.enabled:
stop_event = threading.Event()
stop_event.set()
noop = RecostHandle(timer_stop=stop_event, timer_thread=None, transport=None)
_handle = noop
return noop

registry = ProviderRegistry(config.custom_providers or None)
aggregator = Aggregator(
project_id=config.project_id or "",
environment=config.environment,
sdk_version="0.1.0",
max_buckets=config.max_buckets,
)
transport = Transport(config)
debug = config.debug
max_batch_size = config.max_batch_size

# Build the set of URL substrings to exclude from tracking.
exclude_patterns = list(config.exclude_patterns)
if config.api_key:
exclude_patterns.append(config.base_url.rstrip("/"))
else:
exclude_patterns.append(f"127.0.0.1:{config.local_port}")
exclude_patterns.append(f"localhost:{config.local_port}")

def flush_and_send() -> None:
summary = aggregator.flush()
if summary is None:
return
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)

def on_event(event: RawEvent) -> None:
# Drop excluded URLs
for pattern in exclude_patterns:
if pattern in event.url or pattern in event.host:
return

# Enrich with provider/endpoint from the registry
match = registry.match(event.url)
if match is not None:
event.provider = match.provider
event.endpoint_category = match.endpoint_category

if debug:
print(
f"[recost] captured {event.method} {event.url} "
f"{event.status_code} ({event.latency_ms}ms)",
file=sys.stderr,
)

# If this event would push us past the bucket cap, flush the current
# window first so it's preserved, then ingest into a fresh window.
if aggregator.would_overflow(event):
try:
flush_and_send()
except Exception as err:
if config.on_error:
config.on_error(err)
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

cost = match.cost_per_request_cents if match is not None else 0.0
aggregator.ingest(event, cost)

# Trigger an early flush if the batch size threshold is reached
if aggregator.size >= max_batch_size:
try:
flush_and_send()
except Exception as err:
if config.on_error:
config.on_error(err)
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

install(on_event)

# Flush timer using a background thread with Event-based stopping
stop_event = threading.Event()

# Enrich with provider/endpoint from the registry
match = registry.match(event.url)
if match is not None:
event.provider = match.provider
event.endpoint_category = match.endpoint_category

if debug:
print(
f"[recost] captured {event.method} {event.url} "
f"{event.status_code} ({event.latency_ms}ms)",
file=sys.stderr,
)

# If this event would push us past the bucket cap, flush the current
# window first so it's preserved, then ingest into a fresh window.
if aggregator.would_overflow(event):
try:
flush_and_send()
except Exception as err:
if config.on_error:
config.on_error(err)
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

cost = match.cost_per_request_cents if match is not None else 0.0
aggregator.ingest(event, cost)

# Trigger an early flush if the batch size threshold is reached
if aggregator.size >= max_batch_size:
try:
flush_and_send()
except Exception as err:
if config.on_error:
config.on_error(err)
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

install(on_event)

# Flush timer using a background thread with Event-based stopping
stop_event = threading.Event()

def _timer_loop() -> None:
while not stop_event.wait(timeout=flush_interval_seconds):
def _timer_loop() -> None:
while not stop_event.wait(timeout=flush_interval_seconds):
try:
flush_and_send()
except Exception as err:
if config.on_error:
config.on_error(err)
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

timer_thread = threading.Thread(target=_timer_loop, daemon=True)
timer_thread.start()

def _final_flush() -> None:
# Errors during the final flush are logged / forwarded the same way
# as a normal tick — we never want dispose() to surface them.
try:
flush_and_send()
except Exception as err:
Expand All@@ -201,26 +223,12 @@ def _timer_loop() -> None:
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

timer_thread = threading.Thread(target=_timer_loop, daemon=True)
timer_thread.start()

def _final_flush() -> None:
# Errors during the final flush are logged / forwarded the same way
# as a normal tick — we never want dispose() to surface them.
try:
flush_and_send()
except Exception as err:
if config.on_error:
config.on_error(err)
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

handle = RecostHandle(
timer_stop=stop_event,
timer_thread=timer_thread,
transport=transport,
final_flush=_final_flush,
shutdown_flush_timeout_ms=config.shutdown_flush_timeout_ms,
)
_handle = handle
return handle
handle = RecostHandle(
timer_stop=stop_event,
timer_thread=timer_thread,
transport=transport,
final_flush=_final_flush,
shutdown_flush_timeout_ms=config.shutdown_flush_timeout_ms,
)
_handle = handle
return handle
40 changes: 25 additions & 15 deletions recost/_interceptor.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,7 @@
from __future__ import annotations

import contextvars
import threading
import time
from datetime import datetime, timezone
from typing import Callable, Optional
Expand All@@ -30,6 +31,12 @@
_installed: bool = False
_callback: Optional[EventCallback] = None

# Guards install / uninstall / is_installed so the patched-method state
# cannot drift away from the _installed flag under concurrent callers.
# RLock so the same thread can re-enter (e.g. from a callback that
# triggers reinstall). See issue #4.
_install_lock: threading.RLock = threading.RLock()

# Original function references — restored on uninstall
_original_urllib3_urlopen = None
_original_httpx_send = None
Expand DownExpand Up@@ -372,29 +379,32 @@ def _unpatch_aiohttp() -> None:
def install(callback: EventCallback) -> None:
"""Install patches on urllib3, httpx, and aiohttp. No-op if already installed."""
global _installed, _callback
if _installed:
return
with _install_lock:
if _installed:
return

_callback = callback
_patch_urllib3()
_patch_httpx()
_patch_aiohttp()
_installed = True
_callback = callback
_patch_urllib3()
_patch_httpx()
_patch_aiohttp()
_installed = True


def uninstall() -> None:
"""Restore all patched functions to their originals. No-op if not installed."""
global _installed, _callback
if not _installed:
return
with _install_lock:
if not _installed:
return

_unpatch_urllib3()
_unpatch_httpx()
_unpatch_aiohttp()
_callback = None
_installed = False
_unpatch_urllib3()
_unpatch_httpx()
_unpatch_aiohttp()
_callback = None
_installed = False


def is_installed() -> bool:
"""Returns True if patches are currently active."""
return _installed
with _install_lock:
return _installed
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
292 changes: 150 additions & 142 deletions recost/_init.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -51,32 +51,39 @@ def dispose(self) -> None:
the final flush settles or its timeout elapses, so an in-flight
cloud POST is not cut off mid-request.
"""
if self._disposed:
return
self._disposed = True
with _init_lock:
if self._disposed:
return
self._disposed = True

self._timer_stop.set()
if self._timer_thread is not None:
self._timer_thread.join(timeout=5.0)
self._timer_stop.set()
if self._timer_thread is not None:
self._timer_thread.join(timeout=5.0)

if self._final_flush is not None:
flush_thread = threading.Thread(target=self._final_flush, daemon=True)
flush_thread.start()
flush_thread.join(timeout=self._shutdown_flush_timeout_ms / 1000.0)
if self._final_flush is not None:
flush_thread = threading.Thread(target=self._final_flush, daemon=True)
flush_thread.start()
flush_thread.join(timeout=self._shutdown_flush_timeout_ms / 1000.0)

uninstall()
uninstall()

if self._transport is not None:
self._transport.dispose()
if self._transport is not None:
self._transport.dispose()

global _handle
if _handle is self:
_handle = None
global _handle
if _handle is self:
_handle = None


# Module-level handle so a second init() call disposes the first.
_handle: Optional[RecostHandle] = None

# Guards init() and dispose() so the _handle global cannot become
# inconsistent under concurrent callers. RLock so a wrapping caller
# (e.g. dispose() running on the timer thread which itself owns the
# init lock) does not deadlock. See issue #4.
_init_lock: threading.RLock = threading.RLock()


def init(config: Optional[RecostConfig] = None) -> RecostHandle:
"""
Expand All@@ -87,112 +94,127 @@ def init(config: Optional[RecostConfig] = None) -> RecostHandle:
- Returns a handle with a dispose() method for explicit cleanup.
"""
global _handle
if _handle is not None:
_handle.dispose()

config = config or RecostConfig()

# 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.
if config.flush_interval is not None:
warnings.warn(
"flush_interval is deprecated, use flush_interval_ms instead",
DeprecationWarning,
stacklevel=2,
)
flush_interval_seconds = float(config.flush_interval)
else:
flush_interval_seconds = config.flush_interval_ms / 1000.0

if not config.enabled:
stop_event = threading.Event()
stop_event.set()
noop = RecostHandle(timer_stop=stop_event, timer_thread=None, transport=None)
_handle = noop
return noop

registry = ProviderRegistry(config.custom_providers or None)
aggregator = Aggregator(
project_id=config.project_id or "",
environment=config.environment,
sdk_version="0.1.0",
max_buckets=config.max_buckets,
)
transport = Transport(config)
debug = config.debug
max_batch_size = config.max_batch_size

# Build the set of URL substrings to exclude from tracking.
exclude_patterns = list(config.exclude_patterns)
if config.api_key:
exclude_patterns.append(config.base_url.rstrip("/"))
else:
exclude_patterns.append(f"127.0.0.1:{config.local_port}")
exclude_patterns.append(f"localhost:{config.local_port}")

def flush_and_send() -> None:
summary = aggregator.flush()
if summary is None:
return
if debug:
print(
f"[recost] flush: {len(summary.metrics)} metric group(s), "
f"window {summary.window_start} → {summary.window_end}",
file=sys.stderr,
with _init_lock:
if _handle is not None:
_handle.dispose()

config = config or RecostConfig()

# 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.
if config.flush_interval is not None:
warnings.warn(
"flush_interval is deprecated, use flush_interval_ms instead",
DeprecationWarning,
stacklevel=2,
)
transport.send(summary)

def on_event(event: RawEvent) -> None:
# Drop excluded URLs
for pattern in exclude_patterns:
if pattern in event.url or pattern in event.host:
flush_interval_seconds = float(config.flush_interval)
else:
flush_interval_seconds = config.flush_interval_ms / 1000.0

if not config.enabled:
stop_event = threading.Event()
stop_event.set()
noop = RecostHandle(timer_stop=stop_event, timer_thread=None, transport=None)
_handle = noop
return noop

registry = ProviderRegistry(config.custom_providers or None)
aggregator = Aggregator(
project_id=config.project_id or "",
environment=config.environment,
sdk_version="0.1.0",
max_buckets=config.max_buckets,
)
transport = Transport(config)
debug = config.debug
max_batch_size = config.max_batch_size

# Build the set of URL substrings to exclude from tracking.
exclude_patterns = list(config.exclude_patterns)
if config.api_key:
exclude_patterns.append(config.base_url.rstrip("/"))
else:
exclude_patterns.append(f"127.0.0.1:{config.local_port}")
exclude_patterns.append(f"localhost:{config.local_port}")

def flush_and_send() -> None:
summary = aggregator.flush()
if summary is None:
return
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)

def on_event(event: RawEvent) -> None:
# Drop excluded URLs
for pattern in exclude_patterns:
if pattern in event.url or pattern in event.host:
return

# Enrich with provider/endpoint from the registry
match = registry.match(event.url)
if match is not None:
event.provider = match.provider
event.endpoint_category = match.endpoint_category

if debug:
print(
f"[recost] captured {event.method} {event.url} "
f"{event.status_code} ({event.latency_ms}ms)",
file=sys.stderr,
)

# If this event would push us past the bucket cap, flush the current
# window first so it's preserved, then ingest into a fresh window.
if aggregator.would_overflow(event):
try:
flush_and_send()
except Exception as err:
if config.on_error:
config.on_error(err)
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

cost = match.cost_per_request_cents if match is not None else 0.0
aggregator.ingest(event, cost)

# Trigger an early flush if the batch size threshold is reached
if aggregator.size >= max_batch_size:
try:
flush_and_send()
except Exception as err:
if config.on_error:
config.on_error(err)
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

install(on_event)

# Flush timer using a background thread with Event-based stopping
stop_event = threading.Event()

# Enrich with provider/endpoint from the registry
match = registry.match(event.url)
if match is not None:
event.provider = match.provider
event.endpoint_category = match.endpoint_category

if debug:
print(
f"[recost] captured {event.method} {event.url} "
f"{event.status_code} ({event.latency_ms}ms)",
file=sys.stderr,
)

# If this event would push us past the bucket cap, flush the current
# window first so it's preserved, then ingest into a fresh window.
if aggregator.would_overflow(event):
try:
flush_and_send()
except Exception as err:
if config.on_error:
config.on_error(err)
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

cost = match.cost_per_request_cents if match is not None else 0.0
aggregator.ingest(event, cost)

# Trigger an early flush if the batch size threshold is reached
if aggregator.size >= max_batch_size:
try:
flush_and_send()
except Exception as err:
if config.on_error:
config.on_error(err)
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

install(on_event)

# Flush timer using a background thread with Event-based stopping
stop_event = threading.Event()

def _timer_loop() -> None:
while not stop_event.wait(timeout=flush_interval_seconds):
def _timer_loop() -> None:
while not stop_event.wait(timeout=flush_interval_seconds):
try:
flush_and_send()
except Exception as err:
if config.on_error:
config.on_error(err)
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

timer_thread = threading.Thread(target=_timer_loop, daemon=True)
timer_thread.start()

def _final_flush() -> None:
# Errors during the final flush are logged / forwarded the same way
# as a normal tick — we never want dispose() to surface them.
try:
flush_and_send()
except Exception as err:
Expand All@@ -201,26 +223,12 @@ def _timer_loop() -> None:
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

timer_thread = threading.Thread(target=_timer_loop, daemon=True)
timer_thread.start()

def _final_flush() -> None:
# Errors during the final flush are logged / forwarded the same way
# as a normal tick — we never want dispose() to surface them.
try:
flush_and_send()
except Exception as err:
if config.on_error:
config.on_error(err)
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

handle = RecostHandle(
timer_stop=stop_event,
timer_thread=timer_thread,
transport=transport,
final_flush=_final_flush,
shutdown_flush_timeout_ms=config.shutdown_flush_timeout_ms,
)
_handle = handle
return handle
handle = RecostHandle(
timer_stop=stop_event,
timer_thread=timer_thread,
transport=transport,
final_flush=_final_flush,
shutdown_flush_timeout_ms=config.shutdown_flush_timeout_ms,
)
_handle = handle
return handle
40 changes: 25 additions & 15 deletions recost/_interceptor.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,7 @@
from __future__ import annotations

import contextvars
import threading
import time
from datetime import datetime, timezone
from typing import Callable, Optional
Expand All@@ -30,6 +31,12 @@
_installed: bool = False
_callback: Optional[EventCallback] = None

# Guards install / uninstall / is_installed so the patched-method state
# cannot drift away from the _installed flag under concurrent callers.
# RLock so the same thread can re-enter (e.g. from a callback that
# triggers reinstall). See issue #4.
_install_lock: threading.RLock = threading.RLock()

# Original function references — restored on uninstall
_original_urllib3_urlopen = None
_original_httpx_send = None
Expand DownExpand Up@@ -372,29 +379,32 @@ def _unpatch_aiohttp() -> None:
def install(callback: EventCallback) -> None:
"""Install patches on urllib3, httpx, and aiohttp. No-op if already installed."""
global _installed, _callback
if _installed:
return
with _install_lock:
if _installed:
return

_callback = callback
_patch_urllib3()
_patch_httpx()
_patch_aiohttp()
_installed = True
_callback = callback
_patch_urllib3()
_patch_httpx()
_patch_aiohttp()
_installed = True


def uninstall() -> None:
"""Restore all patched functions to their originals. No-op if not installed."""
global _installed, _callback
if not _installed:
return
with _install_lock:
if not _installed:
return

_unpatch_urllib3()
_unpatch_httpx()
_unpatch_aiohttp()
_callback = None
_installed = False
_unpatch_urllib3()
_unpatch_httpx()
_unpatch_aiohttp()
_callback = None
_installed = False


def is_installed() -> bool:
"""Returns True if patches are currently active."""
return _installed
with _install_lock:
return _installed
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
292 changes: 150 additions & 142 deletions recost/_init.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -51,32 +51,39 @@ def dispose(self) -> None:
the final flush settles or its timeout elapses, so an in-flight
cloud POST is not cut off mid-request.
"""
if self._disposed:
return
self._disposed = True
with _init_lock:
if self._disposed:
return
self._disposed = True

self._timer_stop.set()
if self._timer_thread is not None:
self._timer_thread.join(timeout=5.0)
self._timer_stop.set()
if self._timer_thread is not None:
self._timer_thread.join(timeout=5.0)

if self._final_flush is not None:
flush_thread = threading.Thread(target=self._final_flush, daemon=True)
flush_thread.start()
flush_thread.join(timeout=self._shutdown_flush_timeout_ms / 1000.0)
if self._final_flush is not None:
flush_thread = threading.Thread(target=self._final_flush, daemon=True)
flush_thread.start()
flush_thread.join(timeout=self._shutdown_flush_timeout_ms / 1000.0)

uninstall()
uninstall()

if self._transport is not None:
self._transport.dispose()
if self._transport is not None:
self._transport.dispose()

global _handle
if _handle is self:
_handle = None
global _handle
if _handle is self:
_handle = None


# Module-level handle so a second init() call disposes the first.
_handle: Optional[RecostHandle] = None

# Guards init() and dispose() so the _handle global cannot become
# inconsistent under concurrent callers. RLock so a wrapping caller
# (e.g. dispose() running on the timer thread which itself owns the
# init lock) does not deadlock. See issue #4.
_init_lock: threading.RLock = threading.RLock()


def init(config: Optional[RecostConfig] = None) -> RecostHandle:
"""
Expand All@@ -87,112 +94,127 @@ def init(config: Optional[RecostConfig] = None) -> RecostHandle:
- Returns a handle with a dispose() method for explicit cleanup.
"""
global _handle
if _handle is not None:
_handle.dispose()

config = config or RecostConfig()

# 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.
if config.flush_interval is not None:
warnings.warn(
"flush_interval is deprecated, use flush_interval_ms instead",
DeprecationWarning,
stacklevel=2,
)
flush_interval_seconds = float(config.flush_interval)
else:
flush_interval_seconds = config.flush_interval_ms / 1000.0

if not config.enabled:
stop_event = threading.Event()
stop_event.set()
noop = RecostHandle(timer_stop=stop_event, timer_thread=None, transport=None)
_handle = noop
return noop

registry = ProviderRegistry(config.custom_providers or None)
aggregator = Aggregator(
project_id=config.project_id or "",
environment=config.environment,
sdk_version="0.1.0",
max_buckets=config.max_buckets,
)
transport = Transport(config)
debug = config.debug
max_batch_size = config.max_batch_size

# Build the set of URL substrings to exclude from tracking.
exclude_patterns = list(config.exclude_patterns)
if config.api_key:
exclude_patterns.append(config.base_url.rstrip("/"))
else:
exclude_patterns.append(f"127.0.0.1:{config.local_port}")
exclude_patterns.append(f"localhost:{config.local_port}")

def flush_and_send() -> None:
summary = aggregator.flush()
if summary is None:
return
if debug:
print(
f"[recost] flush: {len(summary.metrics)} metric group(s), "
f"window {summary.window_start} → {summary.window_end}",
file=sys.stderr,
with _init_lock:
if _handle is not None:
_handle.dispose()

config = config or RecostConfig()

# 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.
if config.flush_interval is not None:
warnings.warn(
"flush_interval is deprecated, use flush_interval_ms instead",
DeprecationWarning,
stacklevel=2,
)
transport.send(summary)

def on_event(event: RawEvent) -> None:
# Drop excluded URLs
for pattern in exclude_patterns:
if pattern in event.url or pattern in event.host:
flush_interval_seconds = float(config.flush_interval)
else:
flush_interval_seconds = config.flush_interval_ms / 1000.0

if not config.enabled:
stop_event = threading.Event()
stop_event.set()
noop = RecostHandle(timer_stop=stop_event, timer_thread=None, transport=None)
_handle = noop
return noop

registry = ProviderRegistry(config.custom_providers or None)
aggregator = Aggregator(
project_id=config.project_id or "",
environment=config.environment,
sdk_version="0.1.0",
max_buckets=config.max_buckets,
)
transport = Transport(config)
debug = config.debug
max_batch_size = config.max_batch_size

# Build the set of URL substrings to exclude from tracking.
exclude_patterns = list(config.exclude_patterns)
if config.api_key:
exclude_patterns.append(config.base_url.rstrip("/"))
else:
exclude_patterns.append(f"127.0.0.1:{config.local_port}")
exclude_patterns.append(f"localhost:{config.local_port}")

def flush_and_send() -> None:
summary = aggregator.flush()
if summary is None:
return
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)

def on_event(event: RawEvent) -> None:
# Drop excluded URLs
for pattern in exclude_patterns:
if pattern in event.url or pattern in event.host:
return

# Enrich with provider/endpoint from the registry
match = registry.match(event.url)
if match is not None:
event.provider = match.provider
event.endpoint_category = match.endpoint_category

if debug:
print(
f"[recost] captured {event.method} {event.url} "
f"{event.status_code} ({event.latency_ms}ms)",
file=sys.stderr,
)

# If this event would push us past the bucket cap, flush the current
# window first so it's preserved, then ingest into a fresh window.
if aggregator.would_overflow(event):
try:
flush_and_send()
except Exception as err:
if config.on_error:
config.on_error(err)
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

cost = match.cost_per_request_cents if match is not None else 0.0
aggregator.ingest(event, cost)

# Trigger an early flush if the batch size threshold is reached
if aggregator.size >= max_batch_size:
try:
flush_and_send()
except Exception as err:
if config.on_error:
config.on_error(err)
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

install(on_event)

# Flush timer using a background thread with Event-based stopping
stop_event = threading.Event()

# Enrich with provider/endpoint from the registry
match = registry.match(event.url)
if match is not None:
event.provider = match.provider
event.endpoint_category = match.endpoint_category

if debug:
print(
f"[recost] captured {event.method} {event.url} "
f"{event.status_code} ({event.latency_ms}ms)",
file=sys.stderr,
)

# If this event would push us past the bucket cap, flush the current
# window first so it's preserved, then ingest into a fresh window.
if aggregator.would_overflow(event):
try:
flush_and_send()
except Exception as err:
if config.on_error:
config.on_error(err)
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

cost = match.cost_per_request_cents if match is not None else 0.0
aggregator.ingest(event, cost)

# Trigger an early flush if the batch size threshold is reached
if aggregator.size >= max_batch_size:
try:
flush_and_send()
except Exception as err:
if config.on_error:
config.on_error(err)
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

install(on_event)

# Flush timer using a background thread with Event-based stopping
stop_event = threading.Event()

def _timer_loop() -> None:
while not stop_event.wait(timeout=flush_interval_seconds):
def _timer_loop() -> None:
while not stop_event.wait(timeout=flush_interval_seconds):
try:
flush_and_send()
except Exception as err:
if config.on_error:
config.on_error(err)
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

timer_thread = threading.Thread(target=_timer_loop, daemon=True)
timer_thread.start()

def _final_flush() -> None:
# Errors during the final flush are logged / forwarded the same way
# as a normal tick — we never want dispose() to surface them.
try:
flush_and_send()
except Exception as err:
Expand All@@ -201,26 +223,12 @@ def _timer_loop() -> None:
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

timer_thread = threading.Thread(target=_timer_loop, daemon=True)
timer_thread.start()

def _final_flush() -> None:
# Errors during the final flush are logged / forwarded the same way
# as a normal tick — we never want dispose() to surface them.
try:
flush_and_send()
except Exception as err:
if config.on_error:
config.on_error(err)
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

handle = RecostHandle(
timer_stop=stop_event,
timer_thread=timer_thread,
transport=transport,
final_flush=_final_flush,
shutdown_flush_timeout_ms=config.shutdown_flush_timeout_ms,
)
_handle = handle
return handle
handle = RecostHandle(
timer_stop=stop_event,
timer_thread=timer_thread,
transport=transport,
final_flush=_final_flush,
shutdown_flush_timeout_ms=config.shutdown_flush_timeout_ms,
)
_handle = handle
return handle
40 changes: 25 additions & 15 deletions recost/_interceptor.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,7 @@
from __future__ import annotations

import contextvars
import threading
import time
from datetime import datetime, timezone
from typing import Callable, Optional
Expand All@@ -30,6 +31,12 @@
_installed: bool = False
_callback: Optional[EventCallback] = None

# Guards install / uninstall / is_installed so the patched-method state
# cannot drift away from the _installed flag under concurrent callers.
# RLock so the same thread can re-enter (e.g. from a callback that
# triggers reinstall). See issue #4.
_install_lock: threading.RLock = threading.RLock()

# Original function references — restored on uninstall
_original_urllib3_urlopen = None
_original_httpx_send = None
Expand DownExpand Up@@ -372,29 +379,32 @@ def _unpatch_aiohttp() -> None:
def install(callback: EventCallback) -> None:
"""Install patches on urllib3, httpx, and aiohttp. No-op if already installed."""
global _installed, _callback
if _installed:
return
with _install_lock:
if _installed:
return

_callback = callback
_patch_urllib3()
_patch_httpx()
_patch_aiohttp()
_installed = True
_callback = callback
_patch_urllib3()
_patch_httpx()
_patch_aiohttp()
_installed = True


def uninstall() -> None:
"""Restore all patched functions to their originals. No-op if not installed."""
global _installed, _callback
if not _installed:
return
with _install_lock:
if not _installed:
return

_unpatch_urllib3()
_unpatch_httpx()
_unpatch_aiohttp()
_callback = None
_installed = False
_unpatch_urllib3()
_unpatch_httpx()
_unpatch_aiohttp()
_callback = None
_installed = False


def is_installed() -> bool:
"""Returns True if patches are currently active."""
return _installed
with _install_lock:
return _installed
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
292 changes: 150 additions & 142 deletions recost/_init.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -51,32 +51,39 @@ def dispose(self) -> None:
the final flush settles or its timeout elapses, so an in-flight
cloud POST is not cut off mid-request.
"""
if self._disposed:
return
self._disposed = True
with _init_lock:
if self._disposed:
return
self._disposed = True

self._timer_stop.set()
if self._timer_thread is not None:
self._timer_thread.join(timeout=5.0)
self._timer_stop.set()
if self._timer_thread is not None:
self._timer_thread.join(timeout=5.0)

if self._final_flush is not None:
flush_thread = threading.Thread(target=self._final_flush, daemon=True)
flush_thread.start()
flush_thread.join(timeout=self._shutdown_flush_timeout_ms / 1000.0)
if self._final_flush is not None:
flush_thread = threading.Thread(target=self._final_flush, daemon=True)
flush_thread.start()
flush_thread.join(timeout=self._shutdown_flush_timeout_ms / 1000.0)

uninstall()
uninstall()

if self._transport is not None:
self._transport.dispose()
if self._transport is not None:
self._transport.dispose()

global _handle
if _handle is self:
_handle = None
global _handle
if _handle is self:
_handle = None


# Module-level handle so a second init() call disposes the first.
_handle: Optional[RecostHandle] = None

# Guards init() and dispose() so the _handle global cannot become
# inconsistent under concurrent callers. RLock so a wrapping caller
# (e.g. dispose() running on the timer thread which itself owns the
# init lock) does not deadlock. See issue #4.
_init_lock: threading.RLock = threading.RLock()


def init(config: Optional[RecostConfig] = None) -> RecostHandle:
"""
Expand All@@ -87,112 +94,127 @@ def init(config: Optional[RecostConfig] = None) -> RecostHandle:
- Returns a handle with a dispose() method for explicit cleanup.
"""
global _handle
if _handle is not None:
_handle.dispose()

config = config or RecostConfig()

# 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.
if config.flush_interval is not None:
warnings.warn(
"flush_interval is deprecated, use flush_interval_ms instead",
DeprecationWarning,
stacklevel=2,
)
flush_interval_seconds = float(config.flush_interval)
else:
flush_interval_seconds = config.flush_interval_ms / 1000.0

if not config.enabled:
stop_event = threading.Event()
stop_event.set()
noop = RecostHandle(timer_stop=stop_event, timer_thread=None, transport=None)
_handle = noop
return noop

registry = ProviderRegistry(config.custom_providers or None)
aggregator = Aggregator(
project_id=config.project_id or "",
environment=config.environment,
sdk_version="0.1.0",
max_buckets=config.max_buckets,
)
transport = Transport(config)
debug = config.debug
max_batch_size = config.max_batch_size

# Build the set of URL substrings to exclude from tracking.
exclude_patterns = list(config.exclude_patterns)
if config.api_key:
exclude_patterns.append(config.base_url.rstrip("/"))
else:
exclude_patterns.append(f"127.0.0.1:{config.local_port}")
exclude_patterns.append(f"localhost:{config.local_port}")

def flush_and_send() -> None:
summary = aggregator.flush()
if summary is None:
return
if debug:
print(
f"[recost] flush: {len(summary.metrics)} metric group(s), "
f"window {summary.window_start} → {summary.window_end}",
file=sys.stderr,
with _init_lock:
if _handle is not None:
_handle.dispose()

config = config or RecostConfig()

# 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.
if config.flush_interval is not None:
warnings.warn(
"flush_interval is deprecated, use flush_interval_ms instead",
DeprecationWarning,
stacklevel=2,
)
transport.send(summary)

def on_event(event: RawEvent) -> None:
# Drop excluded URLs
for pattern in exclude_patterns:
if pattern in event.url or pattern in event.host:
flush_interval_seconds = float(config.flush_interval)
else:
flush_interval_seconds = config.flush_interval_ms / 1000.0

if not config.enabled:
stop_event = threading.Event()
stop_event.set()
noop = RecostHandle(timer_stop=stop_event, timer_thread=None, transport=None)
_handle = noop
return noop

registry = ProviderRegistry(config.custom_providers or None)
aggregator = Aggregator(
project_id=config.project_id or "",
environment=config.environment,
sdk_version="0.1.0",
max_buckets=config.max_buckets,
)
transport = Transport(config)
debug = config.debug
max_batch_size = config.max_batch_size

# Build the set of URL substrings to exclude from tracking.
exclude_patterns = list(config.exclude_patterns)
if config.api_key:
exclude_patterns.append(config.base_url.rstrip("/"))
else:
exclude_patterns.append(f"127.0.0.1:{config.local_port}")
exclude_patterns.append(f"localhost:{config.local_port}")

def flush_and_send() -> None:
summary = aggregator.flush()
if summary is None:
return
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)

def on_event(event: RawEvent) -> None:
# Drop excluded URLs
for pattern in exclude_patterns:
if pattern in event.url or pattern in event.host:
return

# Enrich with provider/endpoint from the registry
match = registry.match(event.url)
if match is not None:
event.provider = match.provider
event.endpoint_category = match.endpoint_category

if debug:
print(
f"[recost] captured {event.method} {event.url} "
f"{event.status_code} ({event.latency_ms}ms)",
file=sys.stderr,
)

# If this event would push us past the bucket cap, flush the current
# window first so it's preserved, then ingest into a fresh window.
if aggregator.would_overflow(event):
try:
flush_and_send()
except Exception as err:
if config.on_error:
config.on_error(err)
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

cost = match.cost_per_request_cents if match is not None else 0.0
aggregator.ingest(event, cost)

# Trigger an early flush if the batch size threshold is reached
if aggregator.size >= max_batch_size:
try:
flush_and_send()
except Exception as err:
if config.on_error:
config.on_error(err)
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

install(on_event)

# Flush timer using a background thread with Event-based stopping
stop_event = threading.Event()

# Enrich with provider/endpoint from the registry
match = registry.match(event.url)
if match is not None:
event.provider = match.provider
event.endpoint_category = match.endpoint_category

if debug:
print(
f"[recost] captured {event.method} {event.url} "
f"{event.status_code} ({event.latency_ms}ms)",
file=sys.stderr,
)

# If this event would push us past the bucket cap, flush the current
# window first so it's preserved, then ingest into a fresh window.
if aggregator.would_overflow(event):
try:
flush_and_send()
except Exception as err:
if config.on_error:
config.on_error(err)
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

cost = match.cost_per_request_cents if match is not None else 0.0
aggregator.ingest(event, cost)

# Trigger an early flush if the batch size threshold is reached
if aggregator.size >= max_batch_size:
try:
flush_and_send()
except Exception as err:
if config.on_error:
config.on_error(err)
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

install(on_event)

# Flush timer using a background thread with Event-based stopping
stop_event = threading.Event()

def _timer_loop() -> None:
while not stop_event.wait(timeout=flush_interval_seconds):
def _timer_loop() -> None:
while not stop_event.wait(timeout=flush_interval_seconds):
try:
flush_and_send()
except Exception as err:
if config.on_error:
config.on_error(err)
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

timer_thread = threading.Thread(target=_timer_loop, daemon=True)
timer_thread.start()

def _final_flush() -> None:
# Errors during the final flush are logged / forwarded the same way
# as a normal tick — we never want dispose() to surface them.
try:
flush_and_send()
except Exception as err:
Expand All@@ -201,26 +223,12 @@ def _timer_loop() -> None:
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

timer_thread = threading.Thread(target=_timer_loop, daemon=True)
timer_thread.start()

def _final_flush() -> None:
# Errors during the final flush are logged / forwarded the same way
# as a normal tick — we never want dispose() to surface them.
try:
flush_and_send()
except Exception as err:
if config.on_error:
config.on_error(err)
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

handle = RecostHandle(
timer_stop=stop_event,
timer_thread=timer_thread,
transport=transport,
final_flush=_final_flush,
shutdown_flush_timeout_ms=config.shutdown_flush_timeout_ms,
)
_handle = handle
return handle
handle = RecostHandle(
timer_stop=stop_event,
timer_thread=timer_thread,
transport=transport,
final_flush=_final_flush,
shutdown_flush_timeout_ms=config.shutdown_flush_timeout_ms,
)
_handle = handle
return handle
40 changes: 25 additions & 15 deletions recost/_interceptor.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,7 @@
from __future__ import annotations

import contextvars
import threading
import time
from datetime import datetime, timezone
from typing import Callable, Optional
Expand All@@ -30,6 +31,12 @@
_installed: bool = False
_callback: Optional[EventCallback] = None

# Guards install / uninstall / is_installed so the patched-method state
# cannot drift away from the _installed flag under concurrent callers.
# RLock so the same thread can re-enter (e.g. from a callback that
# triggers reinstall). See issue #4.
_install_lock: threading.RLock = threading.RLock()

# Original function references — restored on uninstall
_original_urllib3_urlopen = None
_original_httpx_send = None
Expand DownExpand Up@@ -372,29 +379,32 @@ def _unpatch_aiohttp() -> None:
def install(callback: EventCallback) -> None:
"""Install patches on urllib3, httpx, and aiohttp. No-op if already installed."""
global _installed, _callback
if _installed:
return
with _install_lock:
if _installed:
return

_callback = callback
_patch_urllib3()
_patch_httpx()
_patch_aiohttp()
_installed = True
_callback = callback
_patch_urllib3()
_patch_httpx()
_patch_aiohttp()
_installed = True


def uninstall() -> None:
"""Restore all patched functions to their originals. No-op if not installed."""
global _installed, _callback
if not _installed:
return
with _install_lock:
if not _installed:
return

_unpatch_urllib3()
_unpatch_httpx()
_unpatch_aiohttp()
_callback = None
_installed = False
_unpatch_urllib3()
_unpatch_httpx()
_unpatch_aiohttp()
_callback = None
_installed = False


def is_installed() -> bool:
"""Returns True if patches are currently active."""
return _installed
with _install_lock:
return _installed
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
292 changes: 150 additions & 142 deletions recost/_init.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -51,32 +51,39 @@ def dispose(self) -> None:
the final flush settles or its timeout elapses, so an in-flight
cloud POST is not cut off mid-request.
"""
if self._disposed:
return
self._disposed = True
with _init_lock:
if self._disposed:
return
self._disposed = True

self._timer_stop.set()
if self._timer_thread is not None:
self._timer_thread.join(timeout=5.0)
self._timer_stop.set()
if self._timer_thread is not None:
self._timer_thread.join(timeout=5.0)

if self._final_flush is not None:
flush_thread = threading.Thread(target=self._final_flush, daemon=True)
flush_thread.start()
flush_thread.join(timeout=self._shutdown_flush_timeout_ms / 1000.0)
if self._final_flush is not None:
flush_thread = threading.Thread(target=self._final_flush, daemon=True)
flush_thread.start()
flush_thread.join(timeout=self._shutdown_flush_timeout_ms / 1000.0)

uninstall()
uninstall()

if self._transport is not None:
self._transport.dispose()
if self._transport is not None:
self._transport.dispose()

global _handle
if _handle is self:
_handle = None
global _handle
if _handle is self:
_handle = None


# Module-level handle so a second init() call disposes the first.
_handle: Optional[RecostHandle] = None

# Guards init() and dispose() so the _handle global cannot become
# inconsistent under concurrent callers. RLock so a wrapping caller
# (e.g. dispose() running on the timer thread which itself owns the
# init lock) does not deadlock. See issue #4.
_init_lock: threading.RLock = threading.RLock()


def init(config: Optional[RecostConfig] = None) -> RecostHandle:
"""
Expand All@@ -87,112 +94,127 @@ def init(config: Optional[RecostConfig] = None) -> RecostHandle:
- Returns a handle with a dispose() method for explicit cleanup.
"""
global _handle
if _handle is not None:
_handle.dispose()

config = config or RecostConfig()

# 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.
if config.flush_interval is not None:
warnings.warn(
"flush_interval is deprecated, use flush_interval_ms instead",
DeprecationWarning,
stacklevel=2,
)
flush_interval_seconds = float(config.flush_interval)
else:
flush_interval_seconds = config.flush_interval_ms / 1000.0

if not config.enabled:
stop_event = threading.Event()
stop_event.set()
noop = RecostHandle(timer_stop=stop_event, timer_thread=None, transport=None)
_handle = noop
return noop

registry = ProviderRegistry(config.custom_providers or None)
aggregator = Aggregator(
project_id=config.project_id or "",
environment=config.environment,
sdk_version="0.1.0",
max_buckets=config.max_buckets,
)
transport = Transport(config)
debug = config.debug
max_batch_size = config.max_batch_size

# Build the set of URL substrings to exclude from tracking.
exclude_patterns = list(config.exclude_patterns)
if config.api_key:
exclude_patterns.append(config.base_url.rstrip("/"))
else:
exclude_patterns.append(f"127.0.0.1:{config.local_port}")
exclude_patterns.append(f"localhost:{config.local_port}")

def flush_and_send() -> None:
summary = aggregator.flush()
if summary is None:
return
if debug:
print(
f"[recost] flush: {len(summary.metrics)} metric group(s), "
f"window {summary.window_start} → {summary.window_end}",
file=sys.stderr,
with _init_lock:
if _handle is not None:
_handle.dispose()

config = config or RecostConfig()

# 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.
if config.flush_interval is not None:
warnings.warn(
"flush_interval is deprecated, use flush_interval_ms instead",
DeprecationWarning,
stacklevel=2,
)
transport.send(summary)

def on_event(event: RawEvent) -> None:
# Drop excluded URLs
for pattern in exclude_patterns:
if pattern in event.url or pattern in event.host:
flush_interval_seconds = float(config.flush_interval)
else:
flush_interval_seconds = config.flush_interval_ms / 1000.0

if not config.enabled:
stop_event = threading.Event()
stop_event.set()
noop = RecostHandle(timer_stop=stop_event, timer_thread=None, transport=None)
_handle = noop
return noop

registry = ProviderRegistry(config.custom_providers or None)
aggregator = Aggregator(
project_id=config.project_id or "",
environment=config.environment,
sdk_version="0.1.0",
max_buckets=config.max_buckets,
)
transport = Transport(config)
debug = config.debug
max_batch_size = config.max_batch_size

# Build the set of URL substrings to exclude from tracking.
exclude_patterns = list(config.exclude_patterns)
if config.api_key:
exclude_patterns.append(config.base_url.rstrip("/"))
else:
exclude_patterns.append(f"127.0.0.1:{config.local_port}")
exclude_patterns.append(f"localhost:{config.local_port}")

def flush_and_send() -> None:
summary = aggregator.flush()
if summary is None:
return
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)

def on_event(event: RawEvent) -> None:
# Drop excluded URLs
for pattern in exclude_patterns:
if pattern in event.url or pattern in event.host:
return

# Enrich with provider/endpoint from the registry
match = registry.match(event.url)
if match is not None:
event.provider = match.provider
event.endpoint_category = match.endpoint_category

if debug:
print(
f"[recost] captured {event.method} {event.url} "
f"{event.status_code} ({event.latency_ms}ms)",
file=sys.stderr,
)

# If this event would push us past the bucket cap, flush the current
# window first so it's preserved, then ingest into a fresh window.
if aggregator.would_overflow(event):
try:
flush_and_send()
except Exception as err:
if config.on_error:
config.on_error(err)
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

cost = match.cost_per_request_cents if match is not None else 0.0
aggregator.ingest(event, cost)

# Trigger an early flush if the batch size threshold is reached
if aggregator.size >= max_batch_size:
try:
flush_and_send()
except Exception as err:
if config.on_error:
config.on_error(err)
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

install(on_event)

# Flush timer using a background thread with Event-based stopping
stop_event = threading.Event()

# Enrich with provider/endpoint from the registry
match = registry.match(event.url)
if match is not None:
event.provider = match.provider
event.endpoint_category = match.endpoint_category

if debug:
print(
f"[recost] captured {event.method} {event.url} "
f"{event.status_code} ({event.latency_ms}ms)",
file=sys.stderr,
)

# If this event would push us past the bucket cap, flush the current
# window first so it's preserved, then ingest into a fresh window.
if aggregator.would_overflow(event):
try:
flush_and_send()
except Exception as err:
if config.on_error:
config.on_error(err)
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

cost = match.cost_per_request_cents if match is not None else 0.0
aggregator.ingest(event, cost)

# Trigger an early flush if the batch size threshold is reached
if aggregator.size >= max_batch_size:
try:
flush_and_send()
except Exception as err:
if config.on_error:
config.on_error(err)
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

install(on_event)

# Flush timer using a background thread with Event-based stopping
stop_event = threading.Event()

def _timer_loop() -> None:
while not stop_event.wait(timeout=flush_interval_seconds):
def _timer_loop() -> None:
while not stop_event.wait(timeout=flush_interval_seconds):
try:
flush_and_send()
except Exception as err:
if config.on_error:
config.on_error(err)
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

timer_thread = threading.Thread(target=_timer_loop, daemon=True)
timer_thread.start()

def _final_flush() -> None:
# Errors during the final flush are logged / forwarded the same way
# as a normal tick — we never want dispose() to surface them.
try:
flush_and_send()
except Exception as err:
Expand All@@ -201,26 +223,12 @@ def _timer_loop() -> None:
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

timer_thread = threading.Thread(target=_timer_loop, daemon=True)
timer_thread.start()

def _final_flush() -> None:
# Errors during the final flush are logged / forwarded the same way
# as a normal tick — we never want dispose() to surface them.
try:
flush_and_send()
except Exception as err:
if config.on_error:
config.on_error(err)
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

handle = RecostHandle(
timer_stop=stop_event,
timer_thread=timer_thread,
transport=transport,
final_flush=_final_flush,
shutdown_flush_timeout_ms=config.shutdown_flush_timeout_ms,
)
_handle = handle
return handle
handle = RecostHandle(
timer_stop=stop_event,
timer_thread=timer_thread,
transport=transport,
final_flush=_final_flush,
shutdown_flush_timeout_ms=config.shutdown_flush_timeout_ms,
)
_handle = handle
return handle
40 changes: 25 additions & 15 deletions recost/_interceptor.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,7 @@
from __future__ import annotations

import contextvars
import threading
import time
from datetime import datetime, timezone
from typing import Callable, Optional
Expand All@@ -30,6 +31,12 @@
_installed: bool = False
_callback: Optional[EventCallback] = None

# Guards install / uninstall / is_installed so the patched-method state
# cannot drift away from the _installed flag under concurrent callers.
# RLock so the same thread can re-enter (e.g. from a callback that
# triggers reinstall). See issue #4.
_install_lock: threading.RLock = threading.RLock()

# Original function references — restored on uninstall
_original_urllib3_urlopen = None
_original_httpx_send = None
Expand DownExpand Up@@ -372,29 +379,32 @@ def _unpatch_aiohttp() -> None:
def install(callback: EventCallback) -> None:
"""Install patches on urllib3, httpx, and aiohttp. No-op if already installed."""
global _installed, _callback
if _installed:
return
with _install_lock:
if _installed:
return

_callback = callback
_patch_urllib3()
_patch_httpx()
_patch_aiohttp()
_installed = True
_callback = callback
_patch_urllib3()
_patch_httpx()
_patch_aiohttp()
_installed = True


def uninstall() -> None:
"""Restore all patched functions to their originals. No-op if not installed."""
global _installed, _callback
if not _installed:
return
with _install_lock:
if not _installed:
return

_unpatch_urllib3()
_unpatch_httpx()
_unpatch_aiohttp()
_callback = None
_installed = False
_unpatch_urllib3()
_unpatch_httpx()
_unpatch_aiohttp()
_callback = None
_installed = False


def is_installed() -> bool:
"""Returns True if patches are currently active."""
return _installed
with _install_lock:
return _installed
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
292 changes: 150 additions & 142 deletions recost/_init.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -51,32 +51,39 @@ def dispose(self) -> None:
the final flush settles or its timeout elapses, so an in-flight
cloud POST is not cut off mid-request.
"""
if self._disposed:
return
self._disposed = True
with _init_lock:
if self._disposed:
return
self._disposed = True

self._timer_stop.set()
if self._timer_thread is not None:
self._timer_thread.join(timeout=5.0)
self._timer_stop.set()
if self._timer_thread is not None:
self._timer_thread.join(timeout=5.0)

if self._final_flush is not None:
flush_thread = threading.Thread(target=self._final_flush, daemon=True)
flush_thread.start()
flush_thread.join(timeout=self._shutdown_flush_timeout_ms / 1000.0)
if self._final_flush is not None:
flush_thread = threading.Thread(target=self._final_flush, daemon=True)
flush_thread.start()
flush_thread.join(timeout=self._shutdown_flush_timeout_ms / 1000.0)

uninstall()
uninstall()

if self._transport is not None:
self._transport.dispose()
if self._transport is not None:
self._transport.dispose()

global _handle
if _handle is self:
_handle = None
global _handle
if _handle is self:
_handle = None


# Module-level handle so a second init() call disposes the first.
_handle: Optional[RecostHandle] = None

# Guards init() and dispose() so the _handle global cannot become
# inconsistent under concurrent callers. RLock so a wrapping caller
# (e.g. dispose() running on the timer thread which itself owns the
# init lock) does not deadlock. See issue #4.
_init_lock: threading.RLock = threading.RLock()


def init(config: Optional[RecostConfig] = None) -> RecostHandle:
"""
Expand All@@ -87,112 +94,127 @@ def init(config: Optional[RecostConfig] = None) -> RecostHandle:
- Returns a handle with a dispose() method for explicit cleanup.
"""
global _handle
if _handle is not None:
_handle.dispose()

config = config or RecostConfig()

# 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.
if config.flush_interval is not None:
warnings.warn(
"flush_interval is deprecated, use flush_interval_ms instead",
DeprecationWarning,
stacklevel=2,
)
flush_interval_seconds = float(config.flush_interval)
else:
flush_interval_seconds = config.flush_interval_ms / 1000.0

if not config.enabled:
stop_event = threading.Event()
stop_event.set()
noop = RecostHandle(timer_stop=stop_event, timer_thread=None, transport=None)
_handle = noop
return noop

registry = ProviderRegistry(config.custom_providers or None)
aggregator = Aggregator(
project_id=config.project_id or "",
environment=config.environment,
sdk_version="0.1.0",
max_buckets=config.max_buckets,
)
transport = Transport(config)
debug = config.debug
max_batch_size = config.max_batch_size

# Build the set of URL substrings to exclude from tracking.
exclude_patterns = list(config.exclude_patterns)
if config.api_key:
exclude_patterns.append(config.base_url.rstrip("/"))
else:
exclude_patterns.append(f"127.0.0.1:{config.local_port}")
exclude_patterns.append(f"localhost:{config.local_port}")

def flush_and_send() -> None:
summary = aggregator.flush()
if summary is None:
return
if debug:
print(
f"[recost] flush: {len(summary.metrics)} metric group(s), "
f"window {summary.window_start} → {summary.window_end}",
file=sys.stderr,
with _init_lock:
if _handle is not None:
_handle.dispose()

config = config or RecostConfig()

# 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.
if config.flush_interval is not None:
warnings.warn(
"flush_interval is deprecated, use flush_interval_ms instead",
DeprecationWarning,
stacklevel=2,
)
transport.send(summary)

def on_event(event: RawEvent) -> None:
# Drop excluded URLs
for pattern in exclude_patterns:
if pattern in event.url or pattern in event.host:
flush_interval_seconds = float(config.flush_interval)
else:
flush_interval_seconds = config.flush_interval_ms / 1000.0

if not config.enabled:
stop_event = threading.Event()
stop_event.set()
noop = RecostHandle(timer_stop=stop_event, timer_thread=None, transport=None)
_handle = noop
return noop

registry = ProviderRegistry(config.custom_providers or None)
aggregator = Aggregator(
project_id=config.project_id or "",
environment=config.environment,
sdk_version="0.1.0",
max_buckets=config.max_buckets,
)
transport = Transport(config)
debug = config.debug
max_batch_size = config.max_batch_size

# Build the set of URL substrings to exclude from tracking.
exclude_patterns = list(config.exclude_patterns)
if config.api_key:
exclude_patterns.append(config.base_url.rstrip("/"))
else:
exclude_patterns.append(f"127.0.0.1:{config.local_port}")
exclude_patterns.append(f"localhost:{config.local_port}")

def flush_and_send() -> None:
summary = aggregator.flush()
if summary is None:
return
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)

def on_event(event: RawEvent) -> None:
# Drop excluded URLs
for pattern in exclude_patterns:
if pattern in event.url or pattern in event.host:
return

# Enrich with provider/endpoint from the registry
match = registry.match(event.url)
if match is not None:
event.provider = match.provider
event.endpoint_category = match.endpoint_category

if debug:
print(
f"[recost] captured {event.method} {event.url} "
f"{event.status_code} ({event.latency_ms}ms)",
file=sys.stderr,
)

# If this event would push us past the bucket cap, flush the current
# window first so it's preserved, then ingest into a fresh window.
if aggregator.would_overflow(event):
try:
flush_and_send()
except Exception as err:
if config.on_error:
config.on_error(err)
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

cost = match.cost_per_request_cents if match is not None else 0.0
aggregator.ingest(event, cost)

# Trigger an early flush if the batch size threshold is reached
if aggregator.size >= max_batch_size:
try:
flush_and_send()
except Exception as err:
if config.on_error:
config.on_error(err)
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

install(on_event)

# Flush timer using a background thread with Event-based stopping
stop_event = threading.Event()

# Enrich with provider/endpoint from the registry
match = registry.match(event.url)
if match is not None:
event.provider = match.provider
event.endpoint_category = match.endpoint_category

if debug:
print(
f"[recost] captured {event.method} {event.url} "
f"{event.status_code} ({event.latency_ms}ms)",
file=sys.stderr,
)

# If this event would push us past the bucket cap, flush the current
# window first so it's preserved, then ingest into a fresh window.
if aggregator.would_overflow(event):
try:
flush_and_send()
except Exception as err:
if config.on_error:
config.on_error(err)
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

cost = match.cost_per_request_cents if match is not None else 0.0
aggregator.ingest(event, cost)

# Trigger an early flush if the batch size threshold is reached
if aggregator.size >= max_batch_size:
try:
flush_and_send()
except Exception as err:
if config.on_error:
config.on_error(err)
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

install(on_event)

# Flush timer using a background thread with Event-based stopping
stop_event = threading.Event()

def _timer_loop() -> None:
while not stop_event.wait(timeout=flush_interval_seconds):
def _timer_loop() -> None:
while not stop_event.wait(timeout=flush_interval_seconds):
try:
flush_and_send()
except Exception as err:
if config.on_error:
config.on_error(err)
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

timer_thread = threading.Thread(target=_timer_loop, daemon=True)
timer_thread.start()

def _final_flush() -> None:
# Errors during the final flush are logged / forwarded the same way
# as a normal tick — we never want dispose() to surface them.
try:
flush_and_send()
except Exception as err:
Expand All@@ -201,26 +223,12 @@ def _timer_loop() -> None:
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

timer_thread = threading.Thread(target=_timer_loop, daemon=True)
timer_thread.start()

def _final_flush() -> None:
# Errors during the final flush are logged / forwarded the same way
# as a normal tick — we never want dispose() to surface them.
try:
flush_and_send()
except Exception as err:
if config.on_error:
config.on_error(err)
elif debug:
print(f"[recost] flush error: {err}", file=sys.stderr)

handle = RecostHandle(
timer_stop=stop_event,
timer_thread=timer_thread,
transport=transport,
final_flush=_final_flush,
shutdown_flush_timeout_ms=config.shutdown_flush_timeout_ms,
)
_handle = handle
return handle
handle = RecostHandle(
timer_stop=stop_event,
timer_thread=timer_thread,
transport=transport,
final_flush=_final_flush,
shutdown_flush_timeout_ms=config.shutdown_flush_timeout_ms,
)
_handle = handle
return handle
40 changes: 25 additions & 15 deletions recost/_interceptor.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,7 @@
from __future__ import annotations

import contextvars
import threading
import time
from datetime import datetime, timezone
from typing import Callable, Optional
Expand All@@ -30,6 +31,12 @@
_installed: bool = False
_callback: Optional[EventCallback] = None

# Guards install / uninstall / is_installed so the patched-method state
# cannot drift away from the _installed flag under concurrent callers.
# RLock so the same thread can re-enter (e.g. from a callback that
# triggers reinstall). See issue #4.
_install_lock: threading.RLock = threading.RLock()

# Original function references — restored on uninstall
_original_urllib3_urlopen = None
_original_httpx_send = None
Expand DownExpand Up@@ -372,29 +379,32 @@ def _unpatch_aiohttp() -> None:
def install(callback: EventCallback) -> None:
"""Install patches on urllib3, httpx, and aiohttp. No-op if already installed."""
global _installed, _callback
if _installed:
return
with _install_lock:
if _installed:
return

_callback = callback
_patch_urllib3()
_patch_httpx()
_patch_aiohttp()
_installed = True
_callback = callback
_patch_urllib3()
_patch_httpx()
_patch_aiohttp()
_installed = True


def uninstall() -> None:
"""Restore all patched functions to their originals. No-op if not installed."""
global _installed, _callback
if not _installed:
return
with _install_lock:
if not _installed:
return

_unpatch_urllib3()
_unpatch_httpx()
_unpatch_aiohttp()
_callback = None
_installed = False
_unpatch_urllib3()
_unpatch_httpx()
_unpatch_aiohttp()
_callback = None
_installed = False


def is_installed() -> bool:
"""Returns True if patches are currently active."""
return _installed
with _install_lock:
return _installed
Loading