Skip to content

Commit dddfa03

Browse files
authored
Merge pull request #16 from bitmakerla/add-curl-metrics
Add curl_cffi metrics
2 parents 17870c8 + 43ea111 commit dddfa03

6 files changed

Lines changed: 508 additions & 8 deletions

File tree

README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,33 @@ ps-helper create-report scrapy_stats.json
5858
```
5959
This will automatically create a report named scrapy_stats-report.html in the same directory as your metrics file.
6060

61+
### Track curl_cffi Downloaded Bytes
62+
63+
Use the reusable helper to register transfer bytes in Scrapy stats (including `downloader/response_bytes`):
64+
65+
```python
66+
from ps_helper.extensions import record_curl_transfer_bytes
67+
68+
record_curl_transfer_bytes(
69+
stats=self.crawler.stats,
70+
curl_response=curl_resp,
71+
add_to_downloader_response_bytes=True,
72+
)
73+
```
74+
75+
With `MetricsExtension`, this is also reflected in the final JSON report under `resources`.
76+
77+
For automatic tracking in every curl request, use `TrackedCurlSession`:
78+
79+
```python
80+
from ps_helper.extensions import TrackedCurlSession
81+
82+
self.curl_session = TrackedCurlSession(stats=self.crawler.stats)
83+
84+
# keep using get/post as usual
85+
curl_resp = self.curl_session.get(url, impersonate="chrome120")
86+
```
87+
6188
---
6289

6390
## 🕷️ Scrapy URL Blocker Middleware

src/ps_helper/extensions/README.md

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
- **Retry & Timeout Analysis**: Record retry attempts and timeout errors.
1414
- **Resource Monitoring**: Capture memory usage and response bytes.
1515
- **JSON Reports**: Save structured metrics reports to `metrics/YYYY-MM-DD/` folders.
16+
- **curl_cffi Transfer Metrics**: Reusable helper for downloaded/uploaded bytes and optional integration with `downloader/response_bytes`.
1617

1718
------------------------------------------------------------------------
1819

@@ -33,6 +34,9 @@ Optionally configure the number of timeline buckets:
3334

3435
```python
3536
METRICS_TIMELINE_BUCKETS = 30
37+
38+
# If True, curl_cffi downloaded bytes are also added to downloader/response_bytes
39+
PS_HELPER_CURL_ADD_TO_DOWNLOADER_RESPONSE_BYTES = True
3640
```
3741

3842
## Example Usage
@@ -57,6 +61,57 @@ The extension will validate items and detect duplicates automatically.
5761

5862
------------------------------------------------------------------------
5963

64+
## Reusable curl_cffi Byte Tracker
65+
66+
You can use this helper from any project to record transfer bytes from `curl_cffi` responses:
67+
68+
```python
69+
from ps_helper.extensions import record_curl_transfer_bytes
70+
71+
# inside a spider or custom download handler
72+
record_curl_transfer_bytes(
73+
stats=self.crawler.stats,
74+
curl_response=curl_resp,
75+
add_to_downloader_response_bytes=True,
76+
)
77+
```
78+
79+
What it records:
80+
81+
- `curl_cffi/bytes_down`
82+
- `curl_cffi/bytes_up`
83+
- `curl_cffi/bytes_total`
84+
- `curl_cffi/response_count`
85+
- Optional: `downloader/response_bytes` (downloaded bytes)
86+
87+
The helper prefers curl/libcurl transfer sizes when available and falls back to `len(response.content)`.
88+
89+
## TrackedCurlSession (Recommended)
90+
91+
For automatic tracking on every request, use `TrackedCurlSession` as a drop-in wrapper:
92+
93+
```python
94+
from ps_helper.extensions import TrackedCurlSession
95+
96+
class MySpider(scrapy.Spider):
97+
name = "my_spider"
98+
99+
@classmethod
100+
def from_crawler(cls, crawler, *args, **kwargs):
101+
spider = super().from_crawler(crawler, *args, **kwargs)
102+
spider.curl_session = TrackedCurlSession(
103+
stats=crawler.stats,
104+
add_to_downloader_response_bytes=crawler.settings.getbool(
105+
"PS_HELPER_CURL_ADD_TO_DOWNLOADER_RESPONSE_BYTES", True
106+
),
107+
)
108+
return spider
109+
```
110+
111+
Then keep using `self.curl_session.get(...)` / `post(...)` normally.
112+
113+
------------------------------------------------------------------------
114+
60115

61116
## Metrics Report
62117

@@ -123,4 +178,3 @@ Example structure:
123178
}
124179
```
125180
------------------------------------------------------------------------
126-
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .curl_metrics import TrackedCurlSession, record_curl_transfer_bytes
2+
3+
__all__ = ["record_curl_transfer_bytes", "TrackedCurlSession"]
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
"""Helpers to record curl_cffi transfer metrics in Scrapy stats."""
2+
3+
from __future__ import annotations
4+
5+
from curl_cffi import requests as curl_requests
6+
from curl_cffi.const import CurlInfo
7+
8+
9+
class TrackedCurlSession:
10+
"""Wrapper around curl_cffi Session that auto-records transfer bytes."""
11+
12+
def __init__(
13+
self,
14+
*,
15+
stats,
16+
session=None,
17+
add_to_downloader_response_bytes=True,
18+
dedupe_on_response=True,
19+
):
20+
self.stats = stats
21+
self.add_to_downloader_response_bytes = add_to_downloader_response_bytes
22+
self.dedupe_on_response = dedupe_on_response
23+
self._session = session if session is not None else self._build_default_session()
24+
25+
@staticmethod
26+
def _build_default_session():
27+
return curl_requests.Session()
28+
29+
def _track(self, response):
30+
record_curl_transfer_bytes(
31+
self.stats,
32+
response,
33+
add_to_downloader_response_bytes=self.add_to_downloader_response_bytes,
34+
dedupe_on_response=self.dedupe_on_response,
35+
)
36+
return response
37+
38+
def request(self, method, url, *args, **kwargs):
39+
response = self._session.request(method, url, *args, **kwargs)
40+
return self._track(response)
41+
42+
def get(self, url, *args, **kwargs):
43+
return self.request("GET", url, *args, **kwargs)
44+
45+
def post(self, url, *args, **kwargs):
46+
return self.request("POST", url, *args, **kwargs)
47+
48+
def put(self, url, *args, **kwargs):
49+
return self.request("PUT", url, *args, **kwargs)
50+
51+
def patch(self, url, *args, **kwargs):
52+
return self.request("PATCH", url, *args, **kwargs)
53+
54+
def delete(self, url, *args, **kwargs):
55+
return self.request("DELETE", url, *args, **kwargs)
56+
57+
def head(self, url, *args, **kwargs):
58+
return self.request("HEAD", url, *args, **kwargs)
59+
60+
def options(self, url, *args, **kwargs):
61+
return self.request("OPTIONS", url, *args, **kwargs)
62+
63+
def close(self):
64+
close_fn = getattr(self._session, "close", None)
65+
if callable(close_fn):
66+
close_fn()
67+
68+
def __enter__(self):
69+
return self
70+
71+
def __exit__(self, exc_type, exc_val, exc_tb):
72+
self.close()
73+
return False
74+
75+
def __getattr__(self, item):
76+
return getattr(self._session, item)
77+
78+
79+
def _safe_int(value):
80+
try:
81+
return int(value or 0)
82+
except Exception:
83+
return 0
84+
85+
86+
def _extract_transfer_sizes(curl_response):
87+
"""Return tuple: (down_bytes, up_bytes)."""
88+
download_size = 0
89+
upload_size = 0
90+
header_size = 0
91+
request_size = 0
92+
93+
curl_handle = getattr(curl_response, "curl", None)
94+
if curl_handle is not None:
95+
try:
96+
download_size = _safe_int(curl_handle.getinfo(CurlInfo.SIZE_DOWNLOAD_T))
97+
upload_size = _safe_int(curl_handle.getinfo(CurlInfo.SIZE_UPLOAD_T))
98+
header_size = _safe_int(curl_handle.getinfo(CurlInfo.HEADER_SIZE))
99+
request_size = _safe_int(curl_handle.getinfo(CurlInfo.REQUEST_SIZE))
100+
except Exception:
101+
download_size = 0
102+
upload_size = 0
103+
header_size = 0
104+
request_size = 0
105+
106+
if (download_size + header_size) == 0:
107+
download_size = len(getattr(curl_response, "content", b"") or b"")
108+
109+
down_bytes = download_size + header_size
110+
up_bytes = request_size + upload_size
111+
return down_bytes, up_bytes
112+
113+
114+
def record_curl_transfer_bytes(
115+
stats,
116+
curl_response,
117+
*,
118+
add_to_downloader_response_bytes=True,
119+
dedupe_on_response=True,
120+
):
121+
"""Record curl_cffi transfer metrics in Scrapy stats.
122+
123+
Args:
124+
stats: Scrapy stats collector.
125+
curl_response: response object returned by curl_cffi.
126+
add_to_downloader_response_bytes: if True, increment
127+
``downloader/response_bytes`` with downloaded bytes.
128+
dedupe_on_response: if True, skip if metrics were already recorded for
129+
this response instance.
130+
"""
131+
if stats is None or curl_response is None:
132+
return {"down_bytes": 0, "up_bytes": 0, "total_bytes": 0}
133+
134+
if dedupe_on_response and getattr(curl_response, "_ps_helper_curl_metrics_recorded", False):
135+
return {"down_bytes": 0, "up_bytes": 0, "total_bytes": 0}
136+
137+
down_bytes, up_bytes = _extract_transfer_sizes(curl_response)
138+
total_bytes = down_bytes + up_bytes
139+
140+
stats.inc_value("curl_cffi/bytes_down", down_bytes)
141+
stats.inc_value("curl_cffi/bytes_up", up_bytes)
142+
stats.inc_value("curl_cffi/bytes_total", total_bytes)
143+
stats.inc_value("curl_cffi/response_count", 1)
144+
145+
if add_to_downloader_response_bytes:
146+
stats.inc_value("downloader/response_bytes", down_bytes)
147+
148+
if dedupe_on_response:
149+
try:
150+
setattr(curl_response, "_ps_helper_curl_metrics_recorded", True)
151+
except Exception:
152+
pass
153+
154+
return {
155+
"down_bytes": down_bytes,
156+
"up_bytes": up_bytes,
157+
"total_bytes": total_bytes,
158+
}

src/ps_helper/extensions/metrics_extension.py

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
1-
import os
2-
import time
1+
import datetime
32
import json
43
import math
5-
import datetime
4+
import os
5+
import time
66
from collections import defaultdict
7-
from ..scripts.generate_report import generate_html_report
8-
from ..scripts.utils import upload_html_to_s3
97

10-
from scrapy import signals
118
from pydantic import ValidationError
9+
from scrapy import signals
10+
11+
from ..scripts.generate_report import generate_html_report
12+
from ..scripts.utils import upload_html_to_s3
13+
from .curl_metrics import record_curl_transfer_bytes
1214

1315

1416
class MetricsExtension:
@@ -40,6 +42,7 @@ def __init__(self, stats, schema=None, unique_field=None, max_buckets=30, items_
4042
self.unique_field = unique_field
4143

4244
self.items_expected = items_expected
45+
self.curl_add_to_downloader_response_bytes = True
4346

4447
@classmethod
4548
def from_crawler(cls, crawler):
@@ -48,6 +51,9 @@ def from_crawler(cls, crawler):
4851

4952
max_buckets = crawler.settings.getint("METRICS_TIMELINE_BUCKETS", 30)
5053
items_expected = getattr(crawler.spidercls, "ITEMS_EXPECTED", None)
54+
curl_add_to_downloader_response_bytes = crawler.settings.getbool(
55+
"PS_HELPER_CURL_ADD_TO_DOWNLOADER_RESPONSE_BYTES", True
56+
)
5157

5258
ext = cls(
5359
crawler.stats,
@@ -56,6 +62,7 @@ def from_crawler(cls, crawler):
5662
max_buckets=max_buckets,
5763
items_expected=items_expected
5864
)
65+
ext.curl_add_to_downloader_response_bytes = curl_add_to_downloader_response_bytes
5966

6067
crawler.signals.connect(ext.spider_opened, signal=signals.spider_opened)
6168
crawler.signals.connect(ext.spider_closed, signal=signals.spider_closed)
@@ -95,7 +102,7 @@ def item_scraped(self, item, spider):
95102
self.field_coverage[field]["complete"] += 1
96103

97104
# Temporal timeline: save timestamp in seconds
98-
elapsed_seconds = int(time.time() - self.start_time)
105+
elapsed_seconds = int(time.time() - (self.start_time or time.time()))
99106
self.timeline[elapsed_seconds] += 1
100107

101108
# Check duplicates if unique_field is defined
@@ -109,7 +116,18 @@ def item_scraped(self, item, spider):
109116
def spider_error(self, failure, response, spider):
110117
self.stats.inc_value("custom/errors")
111118

119+
def record_curl_response(self, curl_response):
120+
"""Public helper to register curl_cffi transfer bytes from spiders/handlers."""
121+
return record_curl_transfer_bytes(
122+
self.stats,
123+
curl_response,
124+
add_to_downloader_response_bytes=self.curl_add_to_downloader_response_bytes,
125+
)
126+
112127
def spider_closed(self, spider, reason):
128+
if self.start_time is None:
129+
self.start_time = time.time()
130+
113131
elapsed = time.time() - self.start_time
114132
total_minutes = elapsed / 60
115133

@@ -140,6 +158,7 @@ def spider_closed(self, spider, reason):
140158
else:
141159
efficiency_factor = 0.65 # 35% penalización (muy ineficiente)
142160

161+
goal_achievement = None
143162
if self.items_expected:
144163
goal_achievement = (items / self.items_expected * 100) if self.items_expected > 0 else 0
145164

@@ -194,6 +213,10 @@ def spider_closed(self, spider, reason):
194213
# Memory and bytes
195214
peak_mem = self.stats.get_value("memusage/max", 0) # bytes
196215
total_bytes = self.stats.get_value("downloader/response_bytes", 0)
216+
curl_bytes_down = self.stats.get_value("curl_cffi/bytes_down", 0)
217+
curl_bytes_up = self.stats.get_value("curl_cffi/bytes_up", 0)
218+
curl_bytes_total = self.stats.get_value("curl_cffi/bytes_total", 0)
219+
curl_response_count = self.stats.get_value("curl_cffi/response_count", 0)
197220

198221
metrics = {
199222
"spider_name": spider.name,
@@ -223,6 +246,16 @@ def spider_closed(self, spider, reason):
223246
"resources": {
224247
"peak_memory_bytes": peak_mem,
225248
"downloaded_bytes": total_bytes,
249+
"downloaded_kb": round(total_bytes / 1024, 2),
250+
"downloaded_mb": round(total_bytes / (1024 * 1024), 2),
251+
"curl_cffi": {
252+
"response_count": curl_response_count,
253+
"bytes_down": curl_bytes_down,
254+
"bytes_up": curl_bytes_up,
255+
"bytes_total": curl_bytes_total,
256+
"bytes_total_kb": round(curl_bytes_total / 1024, 2),
257+
"bytes_total_mb": round(curl_bytes_total / (1024 * 1024), 2),
258+
},
226259
},
227260
"timeline": timeline_sorted,
228261
"timeline_interval_minutes": interval_size,
@@ -257,6 +290,8 @@ def _upload_report_to_s3(self, html_content, spider):
257290
"""Upload HTML report to S3 from memory"""
258291

259292
bucket_name = os.getenv('S3_BUCKET_NAME')
293+
if not bucket_name:
294+
raise ValueError("S3_BUCKET_NAME is required to upload report")
260295

261296
expiration_days = int(os.getenv('REPORT_EXPIRATION_DAYS', '3'))
262297
expiration_seconds = expiration_days * 24 * 3600

0 commit comments

Comments
 (0)