smello 0.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- smello-0.1.1/.bumpversion.toml +16 -0
- smello-0.1.1/.gitignore +23 -0
- smello-0.1.1/PKG-INFO +93 -0
- smello-0.1.1/README.md +70 -0
- smello-0.1.1/pyproject.toml +34 -0
- smello-0.1.1/src/smello/__init__.py +49 -0
- smello-0.1.1/src/smello/capture.py +73 -0
- smello-0.1.1/src/smello/config.py +22 -0
- smello-0.1.1/src/smello/patches/__init__.py +12 -0
- smello-0.1.1/src/smello/patches/patch_httpx.py +93 -0
- smello-0.1.1/src/smello/patches/patch_requests.py +50 -0
- smello-0.1.1/src/smello/transport.py +49 -0
- smello-0.1.1/tests/test_capture.py +155 -0
- smello-0.1.1/tests/test_config.py +46 -0
- smello-0.1.1/tests/test_transport.py +55 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[tool.bumpversion]
|
|
2
|
+
current_version = "0.1.1"
|
|
3
|
+
commit = true
|
|
4
|
+
tag = true
|
|
5
|
+
tag_name = "smello/v{new_version}"
|
|
6
|
+
tag_message = "Release smello v{new_version}"
|
|
7
|
+
message = "Bump smello version: {current_version} → {new_version}"
|
|
8
|
+
|
|
9
|
+
[[tool.bumpversion.files]]
|
|
10
|
+
filename = "clients/python/pyproject.toml"
|
|
11
|
+
key_path = "project.version"
|
|
12
|
+
|
|
13
|
+
[[tool.bumpversion.files]]
|
|
14
|
+
filename = "clients/python/src/smello/__init__.py"
|
|
15
|
+
search = '__version__ = "{current_version}"'
|
|
16
|
+
replace = '__version__ = "{new_version}"'
|
smello-0.1.1/.gitignore
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.py[cod]
|
|
3
|
+
*$py.class
|
|
4
|
+
*.egg-info/
|
|
5
|
+
dist/
|
|
6
|
+
build/
|
|
7
|
+
*.egg
|
|
8
|
+
.eggs/
|
|
9
|
+
*.so
|
|
10
|
+
.venv/
|
|
11
|
+
venv/
|
|
12
|
+
env/
|
|
13
|
+
.env
|
|
14
|
+
*.db
|
|
15
|
+
*.sqlite3
|
|
16
|
+
.mypy_cache/
|
|
17
|
+
.pytest_cache/
|
|
18
|
+
.ruff_cache/
|
|
19
|
+
.coverage
|
|
20
|
+
htmlcov/
|
|
21
|
+
*.log
|
|
22
|
+
.DS_Store
|
|
23
|
+
Thumbs.db
|
smello-0.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: smello
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Capture outgoing HTTP requests and inspect them in a local web dashboard
|
|
5
|
+
Project-URL: Homepage, https://github.com/smelloscope/smello
|
|
6
|
+
Project-URL: Repository, https://github.com/smelloscope/smello
|
|
7
|
+
Project-URL: Issues, https://github.com/smelloscope/smello/issues
|
|
8
|
+
Author-email: Roman Imankulov <roman.imankulov@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Topic :: Software Development :: Testing
|
|
20
|
+
Classifier: Topic :: System :: Networking :: Monitoring
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# Smello
|
|
25
|
+
|
|
26
|
+
Capture outgoing HTTP requests from your Python code and browse them in a local web dashboard.
|
|
27
|
+
|
|
28
|
+
Like [Mailpit](https://mailpit.axllent.org/), but for HTTP requests.
|
|
29
|
+
|
|
30
|
+
## Setup
|
|
31
|
+
|
|
32
|
+
Install the client SDK and the server:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install smello smello-server
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Start the server:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
smello-server run
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Add two lines to your code:
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
import smello
|
|
48
|
+
smello.init()
|
|
49
|
+
|
|
50
|
+
import requests
|
|
51
|
+
resp = requests.get("https://api.stripe.com/v1/charges")
|
|
52
|
+
|
|
53
|
+
# Browse captured requests at http://localhost:5110
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Smello monkey-patches `requests` and `httpx` to capture all outgoing HTTP traffic. Browse results at `http://localhost:5110`.
|
|
57
|
+
|
|
58
|
+
## What Smello Captures
|
|
59
|
+
|
|
60
|
+
- Method, URL, headers, and body
|
|
61
|
+
- Response status code, headers, and body
|
|
62
|
+
- Duration in milliseconds
|
|
63
|
+
- HTTP library used (requests or httpx)
|
|
64
|
+
|
|
65
|
+
Smello redacts sensitive headers (`Authorization`, `X-Api-Key`) by default.
|
|
66
|
+
|
|
67
|
+
## Configuration
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
smello.init(
|
|
71
|
+
server_url="http://localhost:5110", # where to send captured data
|
|
72
|
+
capture_hosts=["api.stripe.com"], # only capture these hosts
|
|
73
|
+
capture_all=True, # capture everything (default)
|
|
74
|
+
ignore_hosts=["localhost"], # skip these hosts
|
|
75
|
+
redact_headers=["Authorization"], # replace values with [REDACTED]
|
|
76
|
+
enabled=True, # kill switch
|
|
77
|
+
)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Supported Libraries
|
|
81
|
+
|
|
82
|
+
- **requests** — patches `Session.send()`
|
|
83
|
+
- **httpx** — patches `Client.send()` and `AsyncClient.send()`
|
|
84
|
+
|
|
85
|
+
## Requires
|
|
86
|
+
|
|
87
|
+
- Python >= 3.10
|
|
88
|
+
- [smello-server](https://pypi.org/project/smello-server/) running locally
|
|
89
|
+
|
|
90
|
+
## Links
|
|
91
|
+
|
|
92
|
+
- [Documentation & Source](https://github.com/smelloscope/smello)
|
|
93
|
+
- [smello-server on PyPI](https://pypi.org/project/smello-server/)
|
smello-0.1.1/README.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Smello
|
|
2
|
+
|
|
3
|
+
Capture outgoing HTTP requests from your Python code and browse them in a local web dashboard.
|
|
4
|
+
|
|
5
|
+
Like [Mailpit](https://mailpit.axllent.org/), but for HTTP requests.
|
|
6
|
+
|
|
7
|
+
## Setup
|
|
8
|
+
|
|
9
|
+
Install the client SDK and the server:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install smello smello-server
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Start the server:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
smello-server run
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Add two lines to your code:
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
import smello
|
|
25
|
+
smello.init()
|
|
26
|
+
|
|
27
|
+
import requests
|
|
28
|
+
resp = requests.get("https://api.stripe.com/v1/charges")
|
|
29
|
+
|
|
30
|
+
# Browse captured requests at http://localhost:5110
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Smello monkey-patches `requests` and `httpx` to capture all outgoing HTTP traffic. Browse results at `http://localhost:5110`.
|
|
34
|
+
|
|
35
|
+
## What Smello Captures
|
|
36
|
+
|
|
37
|
+
- Method, URL, headers, and body
|
|
38
|
+
- Response status code, headers, and body
|
|
39
|
+
- Duration in milliseconds
|
|
40
|
+
- HTTP library used (requests or httpx)
|
|
41
|
+
|
|
42
|
+
Smello redacts sensitive headers (`Authorization`, `X-Api-Key`) by default.
|
|
43
|
+
|
|
44
|
+
## Configuration
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
smello.init(
|
|
48
|
+
server_url="http://localhost:5110", # where to send captured data
|
|
49
|
+
capture_hosts=["api.stripe.com"], # only capture these hosts
|
|
50
|
+
capture_all=True, # capture everything (default)
|
|
51
|
+
ignore_hosts=["localhost"], # skip these hosts
|
|
52
|
+
redact_headers=["Authorization"], # replace values with [REDACTED]
|
|
53
|
+
enabled=True, # kill switch
|
|
54
|
+
)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Supported Libraries
|
|
58
|
+
|
|
59
|
+
- **requests** — patches `Session.send()`
|
|
60
|
+
- **httpx** — patches `Client.send()` and `AsyncClient.send()`
|
|
61
|
+
|
|
62
|
+
## Requires
|
|
63
|
+
|
|
64
|
+
- Python >= 3.10
|
|
65
|
+
- [smello-server](https://pypi.org/project/smello-server/) running locally
|
|
66
|
+
|
|
67
|
+
## Links
|
|
68
|
+
|
|
69
|
+
- [Documentation & Source](https://github.com/smelloscope/smello)
|
|
70
|
+
- [smello-server on PyPI](https://pypi.org/project/smello-server/)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "smello"
|
|
3
|
+
version = "0.1.1"
|
|
4
|
+
description = "Capture outgoing HTTP requests and inspect them in a local web dashboard"
|
|
5
|
+
license = "MIT"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
authors = [{ name = "Roman Imankulov", email = "roman.imankulov@gmail.com" }]
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Development Status :: 3 - Alpha",
|
|
11
|
+
"Intended Audience :: Developers",
|
|
12
|
+
"License :: OSI Approved :: MIT License",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Programming Language :: Python :: 3.10",
|
|
15
|
+
"Programming Language :: Python :: 3.11",
|
|
16
|
+
"Programming Language :: Python :: 3.12",
|
|
17
|
+
"Programming Language :: Python :: 3.13",
|
|
18
|
+
"Programming Language :: Python :: 3.14",
|
|
19
|
+
"Topic :: Software Development :: Testing",
|
|
20
|
+
"Topic :: System :: Networking :: Monitoring",
|
|
21
|
+
]
|
|
22
|
+
dependencies = []
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://github.com/smelloscope/smello"
|
|
26
|
+
Repository = "https://github.com/smelloscope/smello"
|
|
27
|
+
Issues = "https://github.com/smelloscope/smello/issues"
|
|
28
|
+
|
|
29
|
+
[build-system]
|
|
30
|
+
requires = ["hatchling"]
|
|
31
|
+
build-backend = "hatchling.build"
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.targets.wheel]
|
|
34
|
+
packages = ["src/smello"]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Smello - Capture outgoing HTTP requests automatically."""
|
|
2
|
+
|
|
3
|
+
from smello.config import SmelloConfig
|
|
4
|
+
|
|
5
|
+
__version__ = "0.1.1"
|
|
6
|
+
|
|
7
|
+
_config: SmelloConfig | None = None
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def init(
|
|
11
|
+
server_url: str = "http://localhost:5110",
|
|
12
|
+
capture_hosts: list[str] | None = None,
|
|
13
|
+
capture_all: bool = True,
|
|
14
|
+
ignore_hosts: list[str] | None = None,
|
|
15
|
+
redact_headers: list[str] | None = None,
|
|
16
|
+
enabled: bool = True,
|
|
17
|
+
) -> None:
|
|
18
|
+
"""Initialize Smello. Patches requests and httpx to capture outgoing HTTP traffic."""
|
|
19
|
+
global _config
|
|
20
|
+
|
|
21
|
+
if not enabled:
|
|
22
|
+
return
|
|
23
|
+
|
|
24
|
+
_config = SmelloConfig(
|
|
25
|
+
server_url=server_url.rstrip("/"),
|
|
26
|
+
capture_hosts=capture_hosts or [],
|
|
27
|
+
capture_all=capture_all,
|
|
28
|
+
ignore_hosts=ignore_hosts or [],
|
|
29
|
+
redact_headers=[
|
|
30
|
+
h.lower() for h in (redact_headers or ["authorization", "x-api-key"])
|
|
31
|
+
],
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# Always ignore the smello server itself
|
|
35
|
+
from urllib.parse import urlparse
|
|
36
|
+
|
|
37
|
+
server_host = urlparse(_config.server_url).hostname
|
|
38
|
+
if server_host and server_host not in _config.ignore_hosts:
|
|
39
|
+
_config.ignore_hosts.append(server_host)
|
|
40
|
+
|
|
41
|
+
# Start transport worker
|
|
42
|
+
from smello.transport import start_worker
|
|
43
|
+
|
|
44
|
+
start_worker(_config.server_url)
|
|
45
|
+
|
|
46
|
+
# Apply patches
|
|
47
|
+
from smello.patches import apply_all
|
|
48
|
+
|
|
49
|
+
apply_all(_config)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Serialize captured HTTP request/response pairs for sending to the server."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
import uuid
|
|
5
|
+
|
|
6
|
+
from smello.config import SmelloConfig
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def serialize_request_response(
|
|
10
|
+
config: SmelloConfig,
|
|
11
|
+
method: str,
|
|
12
|
+
url: str,
|
|
13
|
+
request_headers: dict,
|
|
14
|
+
request_body: str | bytes | None,
|
|
15
|
+
status_code: int,
|
|
16
|
+
response_headers: dict,
|
|
17
|
+
response_body: str | bytes | None,
|
|
18
|
+
duration_s: float,
|
|
19
|
+
library: str,
|
|
20
|
+
) -> dict:
|
|
21
|
+
"""Build the capture payload dict."""
|
|
22
|
+
req_headers = _redact_headers(dict(request_headers), config.redact_headers)
|
|
23
|
+
resp_headers = dict(response_headers)
|
|
24
|
+
|
|
25
|
+
req_body_str = _body_to_str(request_body)
|
|
26
|
+
resp_body_str = _body_to_str(response_body)
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
"id": str(uuid.uuid4()),
|
|
30
|
+
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
31
|
+
"duration_ms": int(duration_s * 1000),
|
|
32
|
+
"request": {
|
|
33
|
+
"method": method,
|
|
34
|
+
"url": url,
|
|
35
|
+
"headers": req_headers,
|
|
36
|
+
"body": req_body_str,
|
|
37
|
+
"body_size": len(request_body) if request_body else 0,
|
|
38
|
+
},
|
|
39
|
+
"response": {
|
|
40
|
+
"status_code": status_code,
|
|
41
|
+
"headers": resp_headers,
|
|
42
|
+
"body": resp_body_str,
|
|
43
|
+
"body_size": len(response_body) if response_body else 0,
|
|
44
|
+
},
|
|
45
|
+
"meta": {
|
|
46
|
+
"library": library,
|
|
47
|
+
"python_version": _python_version(),
|
|
48
|
+
"smello_version": "0.1.0",
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _redact_headers(headers: dict, redact_keys: list[str]) -> dict:
|
|
54
|
+
return {
|
|
55
|
+
k: ("[REDACTED]" if k.lower() in redact_keys else v) for k, v in headers.items()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _body_to_str(body: str | bytes | None) -> str | None:
|
|
60
|
+
if body is None:
|
|
61
|
+
return None
|
|
62
|
+
if isinstance(body, bytes):
|
|
63
|
+
try:
|
|
64
|
+
return body.decode("utf-8")
|
|
65
|
+
except UnicodeDecodeError:
|
|
66
|
+
return f"<binary: {len(body)} bytes>"
|
|
67
|
+
return body
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _python_version() -> str:
|
|
71
|
+
import sys
|
|
72
|
+
|
|
73
|
+
return f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Configuration for Smello client."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class SmelloConfig:
|
|
8
|
+
server_url: str = "http://localhost:5110"
|
|
9
|
+
capture_hosts: list[str] = field(default_factory=list)
|
|
10
|
+
capture_all: bool = True
|
|
11
|
+
ignore_hosts: list[str] = field(default_factory=list)
|
|
12
|
+
redact_headers: list[str] = field(
|
|
13
|
+
default_factory=lambda: ["authorization", "x-api-key"]
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
def should_capture(self, host: str) -> bool:
|
|
17
|
+
"""Decide whether to capture a request to the given host."""
|
|
18
|
+
if host in self.ignore_hosts:
|
|
19
|
+
return False
|
|
20
|
+
if self.capture_all:
|
|
21
|
+
return True
|
|
22
|
+
return host in self.capture_hosts
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Monkey-patches for HTTP client libraries."""
|
|
2
|
+
|
|
3
|
+
from smello.config import SmelloConfig
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def apply_all(config: SmelloConfig) -> None:
|
|
7
|
+
"""Apply all available patches."""
|
|
8
|
+
from smello.patches.patch_httpx import patch_httpx
|
|
9
|
+
from smello.patches.patch_requests import patch_requests
|
|
10
|
+
|
|
11
|
+
patch_requests(config)
|
|
12
|
+
patch_httpx(config)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Monkey-patch for the `httpx` library (sync and async)."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from urllib.parse import urlparse
|
|
5
|
+
|
|
6
|
+
from smello.config import SmelloConfig
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def patch_httpx(config: SmelloConfig) -> None:
|
|
10
|
+
"""Patch httpx.Client.send and httpx.AsyncClient.send."""
|
|
11
|
+
try:
|
|
12
|
+
import httpx
|
|
13
|
+
except ImportError:
|
|
14
|
+
return # httpx not installed, skip
|
|
15
|
+
|
|
16
|
+
_patch_sync(httpx, config)
|
|
17
|
+
_patch_async(httpx, config)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _patch_sync(httpx, config: SmelloConfig) -> None:
|
|
21
|
+
original_send = httpx.Client.send
|
|
22
|
+
|
|
23
|
+
def patched_send(self, request, **kwargs):
|
|
24
|
+
host = urlparse(str(request.url)).hostname or ""
|
|
25
|
+
|
|
26
|
+
if not config.should_capture(host):
|
|
27
|
+
return original_send(self, request, **kwargs)
|
|
28
|
+
|
|
29
|
+
start = time.monotonic()
|
|
30
|
+
response = original_send(self, request, **kwargs)
|
|
31
|
+
duration = time.monotonic() - start
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
from smello.capture import serialize_request_response
|
|
35
|
+
from smello.transport import send
|
|
36
|
+
|
|
37
|
+
payload = serialize_request_response(
|
|
38
|
+
config=config,
|
|
39
|
+
method=request.method,
|
|
40
|
+
url=str(request.url),
|
|
41
|
+
request_headers=dict(request.headers),
|
|
42
|
+
request_body=request.content,
|
|
43
|
+
status_code=response.status_code,
|
|
44
|
+
response_headers=dict(response.headers),
|
|
45
|
+
response_body=response.text,
|
|
46
|
+
duration_s=duration,
|
|
47
|
+
library="httpx",
|
|
48
|
+
)
|
|
49
|
+
send(payload)
|
|
50
|
+
except Exception:
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
return response
|
|
54
|
+
|
|
55
|
+
httpx.Client.send = patched_send
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _patch_async(httpx, config: SmelloConfig) -> None:
|
|
59
|
+
original_send = httpx.AsyncClient.send
|
|
60
|
+
|
|
61
|
+
async def patched_send(self, request, **kwargs):
|
|
62
|
+
host = urlparse(str(request.url)).hostname or ""
|
|
63
|
+
|
|
64
|
+
if not config.should_capture(host):
|
|
65
|
+
return await original_send(self, request, **kwargs)
|
|
66
|
+
|
|
67
|
+
start = time.monotonic()
|
|
68
|
+
response = await original_send(self, request, **kwargs)
|
|
69
|
+
duration = time.monotonic() - start
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
from smello.capture import serialize_request_response
|
|
73
|
+
from smello.transport import send
|
|
74
|
+
|
|
75
|
+
payload = serialize_request_response(
|
|
76
|
+
config=config,
|
|
77
|
+
method=request.method,
|
|
78
|
+
url=str(request.url),
|
|
79
|
+
request_headers=dict(request.headers),
|
|
80
|
+
request_body=request.content,
|
|
81
|
+
status_code=response.status_code,
|
|
82
|
+
response_headers=dict(response.headers),
|
|
83
|
+
response_body=response.text,
|
|
84
|
+
duration_s=duration,
|
|
85
|
+
library="httpx",
|
|
86
|
+
)
|
|
87
|
+
send(payload)
|
|
88
|
+
except Exception:
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
return response
|
|
92
|
+
|
|
93
|
+
httpx.AsyncClient.send = patched_send
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Monkey-patch for the `requests` library."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from urllib.parse import urlparse
|
|
5
|
+
|
|
6
|
+
from smello.config import SmelloConfig
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def patch_requests(config: SmelloConfig) -> None:
|
|
10
|
+
"""Patch requests.Session.send to capture outgoing HTTP traffic."""
|
|
11
|
+
try:
|
|
12
|
+
import requests
|
|
13
|
+
except ImportError:
|
|
14
|
+
return # requests not installed, skip
|
|
15
|
+
|
|
16
|
+
original_send = requests.Session.send
|
|
17
|
+
|
|
18
|
+
def patched_send(self, prepared_request, **kwargs):
|
|
19
|
+
host = urlparse(prepared_request.url).hostname or ""
|
|
20
|
+
|
|
21
|
+
if not config.should_capture(host):
|
|
22
|
+
return original_send(self, prepared_request, **kwargs)
|
|
23
|
+
|
|
24
|
+
start = time.monotonic()
|
|
25
|
+
response = original_send(self, prepared_request, **kwargs)
|
|
26
|
+
duration = time.monotonic() - start
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
from smello.capture import serialize_request_response
|
|
30
|
+
from smello.transport import send
|
|
31
|
+
|
|
32
|
+
payload = serialize_request_response(
|
|
33
|
+
config=config,
|
|
34
|
+
method=prepared_request.method or "GET",
|
|
35
|
+
url=prepared_request.url,
|
|
36
|
+
request_headers=dict(prepared_request.headers),
|
|
37
|
+
request_body=prepared_request.body,
|
|
38
|
+
status_code=response.status_code,
|
|
39
|
+
response_headers=dict(response.headers),
|
|
40
|
+
response_body=response.text,
|
|
41
|
+
duration_s=duration,
|
|
42
|
+
library="requests",
|
|
43
|
+
)
|
|
44
|
+
send(payload)
|
|
45
|
+
except Exception:
|
|
46
|
+
pass # never break user's code
|
|
47
|
+
|
|
48
|
+
return response
|
|
49
|
+
|
|
50
|
+
requests.Session.send = patched_send # type: ignore[assignment]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Background transport: sends captured data to the Smello server without blocking."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import queue
|
|
5
|
+
import threading
|
|
6
|
+
import urllib.request
|
|
7
|
+
|
|
8
|
+
_queue: queue.Queue = queue.Queue(maxsize=1000)
|
|
9
|
+
_server_url: str = ""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def start_worker(server_url: str) -> None:
|
|
13
|
+
"""Start the background worker thread."""
|
|
14
|
+
global _server_url
|
|
15
|
+
_server_url = server_url
|
|
16
|
+
|
|
17
|
+
thread = threading.Thread(target=_worker, daemon=True, name="smello-transport")
|
|
18
|
+
thread.start()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def send(payload: dict) -> None:
|
|
22
|
+
"""Queue a capture payload for sending. Non-blocking, drops if queue is full."""
|
|
23
|
+
try:
|
|
24
|
+
_queue.put_nowait(payload)
|
|
25
|
+
except queue.Full:
|
|
26
|
+
pass # drop silently if queue is full
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _worker() -> None:
|
|
30
|
+
"""Background worker that sends queued payloads to the server."""
|
|
31
|
+
while True:
|
|
32
|
+
payload = _queue.get()
|
|
33
|
+
try:
|
|
34
|
+
_send_to_server(payload)
|
|
35
|
+
except Exception:
|
|
36
|
+
pass # silently drop if server is down
|
|
37
|
+
_queue.task_done()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _send_to_server(payload: dict) -> None:
|
|
41
|
+
"""Send a payload to the Smello server using urllib (to avoid recursion)."""
|
|
42
|
+
data = json.dumps(payload).encode("utf-8")
|
|
43
|
+
req = urllib.request.Request(
|
|
44
|
+
f"{_server_url}/api/capture",
|
|
45
|
+
data=data,
|
|
46
|
+
headers={"Content-Type": "application/json"},
|
|
47
|
+
method="POST",
|
|
48
|
+
)
|
|
49
|
+
urllib.request.urlopen(req, timeout=5)
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Tests for smello.capture serialization."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from smello.capture import serialize_request_response
|
|
5
|
+
from smello.config import SmelloConfig
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.fixture()
|
|
9
|
+
def config():
|
|
10
|
+
return SmelloConfig(redact_headers=["authorization", "x-api-key"])
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture()
|
|
14
|
+
def basic_payload(config):
|
|
15
|
+
return serialize_request_response(
|
|
16
|
+
config=config,
|
|
17
|
+
method="GET",
|
|
18
|
+
url="https://api.example.com/test",
|
|
19
|
+
request_headers={"Content-Type": "application/json"},
|
|
20
|
+
request_body=None,
|
|
21
|
+
status_code=200,
|
|
22
|
+
response_headers={"Content-Type": "application/json"},
|
|
23
|
+
response_body='{"ok": true}',
|
|
24
|
+
duration_s=0.15,
|
|
25
|
+
library="requests",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_basic_fields(basic_payload):
|
|
30
|
+
assert basic_payload["duration_ms"] == 150
|
|
31
|
+
assert basic_payload["request"]["method"] == "GET"
|
|
32
|
+
assert basic_payload["request"]["url"] == "https://api.example.com/test"
|
|
33
|
+
assert basic_payload["response"]["status_code"] == 200
|
|
34
|
+
assert basic_payload["response"]["body"] == '{"ok": true}'
|
|
35
|
+
assert basic_payload["meta"]["library"] == "requests"
|
|
36
|
+
assert "id" in basic_payload
|
|
37
|
+
assert "timestamp" in basic_payload
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_null_body(basic_payload):
|
|
41
|
+
assert basic_payload["request"]["body"] is None
|
|
42
|
+
assert basic_payload["request"]["body_size"] == 0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_header_redaction(config):
|
|
46
|
+
payload = serialize_request_response(
|
|
47
|
+
config=config,
|
|
48
|
+
method="POST",
|
|
49
|
+
url="https://example.com",
|
|
50
|
+
request_headers={
|
|
51
|
+
"Content-Type": "application/json",
|
|
52
|
+
"Authorization": "Bearer sk-secret",
|
|
53
|
+
"X-Api-Key": "key_12345",
|
|
54
|
+
"X-Custom": "keep-this",
|
|
55
|
+
},
|
|
56
|
+
request_body=None,
|
|
57
|
+
status_code=200,
|
|
58
|
+
response_headers={},
|
|
59
|
+
response_body=None,
|
|
60
|
+
duration_s=0.1,
|
|
61
|
+
library="httpx",
|
|
62
|
+
)
|
|
63
|
+
headers = payload["request"]["headers"]
|
|
64
|
+
assert headers["Content-Type"] == "application/json"
|
|
65
|
+
assert headers["Authorization"] == "[REDACTED]"
|
|
66
|
+
assert headers["X-Api-Key"] == "[REDACTED]"
|
|
67
|
+
assert headers["X-Custom"] == "keep-this"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_custom_redact_headers():
|
|
71
|
+
config = SmelloConfig(redact_headers=["x-secret"])
|
|
72
|
+
payload = serialize_request_response(
|
|
73
|
+
config=config,
|
|
74
|
+
method="GET",
|
|
75
|
+
url="https://example.com",
|
|
76
|
+
request_headers={"Authorization": "Bearer token", "X-Secret": "hidden"},
|
|
77
|
+
request_body=None,
|
|
78
|
+
status_code=200,
|
|
79
|
+
response_headers={},
|
|
80
|
+
response_body=None,
|
|
81
|
+
duration_s=0.05,
|
|
82
|
+
library="requests",
|
|
83
|
+
)
|
|
84
|
+
headers = payload["request"]["headers"]
|
|
85
|
+
assert headers["Authorization"] == "Bearer token"
|
|
86
|
+
assert headers["X-Secret"] == "[REDACTED]"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_bytes_body_utf8(config):
|
|
90
|
+
payload = serialize_request_response(
|
|
91
|
+
config=config,
|
|
92
|
+
method="POST",
|
|
93
|
+
url="https://example.com",
|
|
94
|
+
request_headers={},
|
|
95
|
+
request_body=b'{"key": "value"}',
|
|
96
|
+
status_code=200,
|
|
97
|
+
response_headers={},
|
|
98
|
+
response_body=b'{"result": "ok"}',
|
|
99
|
+
duration_s=0.1,
|
|
100
|
+
library="requests",
|
|
101
|
+
)
|
|
102
|
+
assert payload["request"]["body"] == '{"key": "value"}'
|
|
103
|
+
assert payload["request"]["body_size"] == 16
|
|
104
|
+
assert payload["response"]["body"] == '{"result": "ok"}'
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_binary_body_non_utf8(config):
|
|
108
|
+
binary_data = bytes(range(256))
|
|
109
|
+
payload = serialize_request_response(
|
|
110
|
+
config=config,
|
|
111
|
+
method="POST",
|
|
112
|
+
url="https://example.com",
|
|
113
|
+
request_headers={},
|
|
114
|
+
request_body=binary_data,
|
|
115
|
+
status_code=200,
|
|
116
|
+
response_headers={},
|
|
117
|
+
response_body=None,
|
|
118
|
+
duration_s=0.1,
|
|
119
|
+
library="requests",
|
|
120
|
+
)
|
|
121
|
+
assert payload["request"]["body"] == "<binary: 256 bytes>"
|
|
122
|
+
assert payload["request"]["body_size"] == 256
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_string_body(config):
|
|
126
|
+
payload = serialize_request_response(
|
|
127
|
+
config=config,
|
|
128
|
+
method="POST",
|
|
129
|
+
url="https://example.com",
|
|
130
|
+
request_headers={},
|
|
131
|
+
request_body="plain text",
|
|
132
|
+
status_code=200,
|
|
133
|
+
response_headers={},
|
|
134
|
+
response_body="response text",
|
|
135
|
+
duration_s=0.1,
|
|
136
|
+
library="httpx",
|
|
137
|
+
)
|
|
138
|
+
assert payload["request"]["body"] == "plain text"
|
|
139
|
+
assert payload["response"]["body"] == "response text"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_duration_rounding(config):
|
|
143
|
+
payload = serialize_request_response(
|
|
144
|
+
config=config,
|
|
145
|
+
method="GET",
|
|
146
|
+
url="https://example.com",
|
|
147
|
+
request_headers={},
|
|
148
|
+
request_body=None,
|
|
149
|
+
status_code=200,
|
|
150
|
+
response_headers={},
|
|
151
|
+
response_body=None,
|
|
152
|
+
duration_s=1.5678,
|
|
153
|
+
library="requests",
|
|
154
|
+
)
|
|
155
|
+
assert payload["duration_ms"] == 1567
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Tests for smello.config.SmelloConfig."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from smello.config import SmelloConfig
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@pytest.fixture()
|
|
8
|
+
def default_config():
|
|
9
|
+
return SmelloConfig()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture()
|
|
13
|
+
def selective_config():
|
|
14
|
+
return SmelloConfig(capture_all=False, capture_hosts=["api.stripe.com"])
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_capture_all_by_default(default_config):
|
|
18
|
+
assert default_config.should_capture("api.stripe.com") is True
|
|
19
|
+
assert default_config.should_capture("example.com") is True
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_ignore_hosts():
|
|
23
|
+
config = SmelloConfig(ignore_hosts=["localhost", "127.0.0.1"])
|
|
24
|
+
assert config.should_capture("localhost") is False
|
|
25
|
+
assert config.should_capture("127.0.0.1") is False
|
|
26
|
+
assert config.should_capture("api.stripe.com") is True
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_capture_specific_hosts_only(selective_config):
|
|
30
|
+
assert selective_config.should_capture("api.stripe.com") is True
|
|
31
|
+
assert selective_config.should_capture("api.openai.com") is False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_ignore_takes_precedence_over_capture_hosts():
|
|
35
|
+
config = SmelloConfig(
|
|
36
|
+
capture_all=False,
|
|
37
|
+
capture_hosts=["api.stripe.com"],
|
|
38
|
+
ignore_hosts=["api.stripe.com"],
|
|
39
|
+
)
|
|
40
|
+
assert config.should_capture("api.stripe.com") is False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_ignore_takes_precedence_over_capture_all():
|
|
44
|
+
config = SmelloConfig(capture_all=True, ignore_hosts=["secret.internal"])
|
|
45
|
+
assert config.should_capture("secret.internal") is False
|
|
46
|
+
assert config.should_capture("anything.else") is True
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Tests for smello.transport."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from smello.transport import send, start_worker
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class _CaptureHandler(BaseHTTPRequestHandler):
|
|
13
|
+
captured: list = []
|
|
14
|
+
|
|
15
|
+
def do_POST(self):
|
|
16
|
+
length = int(self.headers.get("Content-Length", 0))
|
|
17
|
+
body = json.loads(self.rfile.read(length))
|
|
18
|
+
_CaptureHandler.captured.append(body)
|
|
19
|
+
self.send_response(201)
|
|
20
|
+
self.end_headers()
|
|
21
|
+
self.wfile.write(b'{"status":"ok"}')
|
|
22
|
+
|
|
23
|
+
def log_message(self, format, *args):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.fixture()
|
|
28
|
+
def capture_server():
|
|
29
|
+
"""Start a minimal HTTP server that records POSTed payloads."""
|
|
30
|
+
_CaptureHandler.captured = []
|
|
31
|
+
server = HTTPServer(("127.0.0.1", 0), _CaptureHandler)
|
|
32
|
+
port = server.server_address[1]
|
|
33
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
34
|
+
thread.start()
|
|
35
|
+
yield f"http://127.0.0.1:{port}", _CaptureHandler.captured
|
|
36
|
+
server.shutdown()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_send_delivers_payload(capture_server):
|
|
40
|
+
url, captured = capture_server
|
|
41
|
+
start_worker(url)
|
|
42
|
+
|
|
43
|
+
payload = {
|
|
44
|
+
"id": "test-transport-1",
|
|
45
|
+
"request": {"method": "GET", "url": "https://example.com"},
|
|
46
|
+
"response": {"status_code": 200},
|
|
47
|
+
}
|
|
48
|
+
send(payload)
|
|
49
|
+
|
|
50
|
+
deadline = time.monotonic() + 5
|
|
51
|
+
while not captured and time.monotonic() < deadline:
|
|
52
|
+
time.sleep(0.05)
|
|
53
|
+
|
|
54
|
+
assert len(captured) == 1
|
|
55
|
+
assert captured[0]["id"] == "test-transport-1"
|