nr-log-handler 0.1.0__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.
- nr_log_handler-0.1.0/PKG-INFO +119 -0
- nr_log_handler-0.1.0/README.md +94 -0
- nr_log_handler-0.1.0/pyproject.toml +38 -0
- nr_log_handler-0.1.0/setup.cfg +4 -0
- nr_log_handler-0.1.0/src/newrelic_logger/__init__.py +12 -0
- nr_log_handler-0.1.0/src/newrelic_logger/batch.py +61 -0
- nr_log_handler-0.1.0/src/newrelic_logger/client.py +78 -0
- nr_log_handler-0.1.0/src/newrelic_logger/exceptions.py +6 -0
- nr_log_handler-0.1.0/src/newrelic_logger/handler.py +72 -0
- nr_log_handler-0.1.0/src/newrelic_logger/logger.py +60 -0
- nr_log_handler-0.1.0/src/nr_log_handler.egg-info/PKG-INFO +119 -0
- nr_log_handler-0.1.0/src/nr_log_handler.egg-info/SOURCES.txt +18 -0
- nr_log_handler-0.1.0/src/nr_log_handler.egg-info/dependency_links.txt +1 -0
- nr_log_handler-0.1.0/src/nr_log_handler.egg-info/requires.txt +5 -0
- nr_log_handler-0.1.0/src/nr_log_handler.egg-info/top_level.txt +1 -0
- nr_log_handler-0.1.0/tests/test_batch.py +64 -0
- nr_log_handler-0.1.0/tests/test_client.py +150 -0
- nr_log_handler-0.1.0/tests/test_exceptions.py +12 -0
- nr_log_handler-0.1.0/tests/test_handler.py +86 -0
- nr_log_handler-0.1.0/tests/test_logger.py +70 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nr-log-handler
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Send logs to New Relic via their Log REST API
|
|
5
|
+
Author-email: Your Name <you@example.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/yourname/newrelic-logger
|
|
8
|
+
Project-URL: Repository, https://github.com/yourname/newrelic-logger
|
|
9
|
+
Keywords: newrelic,logging,observability
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
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: Topic :: System :: Logging
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Requires-Dist: requests>=2.28
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=7.4; extra == "dev"
|
|
24
|
+
Requires-Dist: pytest-mock>=3.11; extra == "dev"
|
|
25
|
+
|
|
26
|
+
# nr-log-handler
|
|
27
|
+
|
|
28
|
+
A lightweight Python library for sending logs to [New Relic](https://newrelic.com) via their [Log API](https://docs.newrelic.com/docs/logs/log-api/introduction-log-api/). No New Relic agent required — pure REST API.
|
|
29
|
+
|
|
30
|
+
## Features
|
|
31
|
+
|
|
32
|
+
- Works with Python's built-in `logging` module as a drop-in `logging.Handler`
|
|
33
|
+
- Standalone `NewRelicLogger` with `.info()`, `.error()` etc.
|
|
34
|
+
- US and EU region support
|
|
35
|
+
- Sync (immediate) and async (batched, background thread) modes
|
|
36
|
+
- Configurable batch size and flush interval
|
|
37
|
+
- Exponential backoff retry — never crashes your application
|
|
38
|
+
- Global and per-call custom attributes
|
|
39
|
+
|
|
40
|
+
## Requirements
|
|
41
|
+
|
|
42
|
+
- Python 3.10+
|
|
43
|
+
- `requests`
|
|
44
|
+
|
|
45
|
+
## Installation
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install nr-log-handler
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Quick Start
|
|
52
|
+
|
|
53
|
+
### Standalone logger
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from newrelic_logger import NewRelicLogger
|
|
57
|
+
|
|
58
|
+
logger = NewRelicLogger(
|
|
59
|
+
api_key="YOUR_NEW_RELIC_LICENSE_KEY",
|
|
60
|
+
region="us", # "us" (default) or "eu"
|
|
61
|
+
mode="async", # "sync" (default) or "async"
|
|
62
|
+
attributes={"service": "my-app", "environment": "production"},
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
logger.info("Application started")
|
|
66
|
+
logger.error("Something went wrong", extra_attributes={"request_id": "abc-123"})
|
|
67
|
+
|
|
68
|
+
# Always call close() on shutdown to flush buffered logs
|
|
69
|
+
logger.close()
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### As a `logging.Handler`
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
import logging
|
|
76
|
+
from newrelic_logger import NewRelicHandler
|
|
77
|
+
|
|
78
|
+
handler = NewRelicHandler(
|
|
79
|
+
api_key="YOUR_NEW_RELIC_LICENSE_KEY",
|
|
80
|
+
region="us",
|
|
81
|
+
mode="sync",
|
|
82
|
+
attributes={"service": "my-app"},
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
logging.basicConfig(level=logging.INFO)
|
|
86
|
+
logger = logging.getLogger("myapp")
|
|
87
|
+
logger.addHandler(handler)
|
|
88
|
+
|
|
89
|
+
logger.info("Hello from standard logging")
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Configuration
|
|
93
|
+
|
|
94
|
+
| Parameter | Type | Default | Description |
|
|
95
|
+
|---|---|---|---|
|
|
96
|
+
| `api_key` | `str` | **required** | New Relic license key |
|
|
97
|
+
| `region` | `str` | `"us"` | `"us"` or `"eu"` |
|
|
98
|
+
| `mode` | `str` | `"sync"` | `"sync"` or `"async"` |
|
|
99
|
+
| `batch_size` | `int` | `100` | Max logs per batch (async only) |
|
|
100
|
+
| `flush_interval` | `float` | `5.0` | Seconds between flushes (async only) |
|
|
101
|
+
| `timeout` | `int` | `10` | HTTP timeout in seconds |
|
|
102
|
+
| `max_retries` | `int` | `5` | Max retry attempts on transient failure |
|
|
103
|
+
| `backoff_factor` | `float` | `0.5` | Exponential backoff multiplier |
|
|
104
|
+
| `attributes` | `dict` | `None` | Global attributes on every log entry |
|
|
105
|
+
|
|
106
|
+
## Error Handling
|
|
107
|
+
|
|
108
|
+
The library **never raises exceptions** from logging calls. On failure it retries up to `max_retries` times with exponential backoff, then emits a warning via Python's stdlib `logging` to the `newrelic_logger` logger and silently drops the batch.
|
|
109
|
+
|
|
110
|
+
To capture these internal warnings:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
import logging
|
|
114
|
+
logging.getLogger("newrelic_logger").setLevel(logging.WARNING)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## License
|
|
118
|
+
|
|
119
|
+
MIT
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# nr-log-handler
|
|
2
|
+
|
|
3
|
+
A lightweight Python library for sending logs to [New Relic](https://newrelic.com) via their [Log API](https://docs.newrelic.com/docs/logs/log-api/introduction-log-api/). No New Relic agent required — pure REST API.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Works with Python's built-in `logging` module as a drop-in `logging.Handler`
|
|
8
|
+
- Standalone `NewRelicLogger` with `.info()`, `.error()` etc.
|
|
9
|
+
- US and EU region support
|
|
10
|
+
- Sync (immediate) and async (batched, background thread) modes
|
|
11
|
+
- Configurable batch size and flush interval
|
|
12
|
+
- Exponential backoff retry — never crashes your application
|
|
13
|
+
- Global and per-call custom attributes
|
|
14
|
+
|
|
15
|
+
## Requirements
|
|
16
|
+
|
|
17
|
+
- Python 3.10+
|
|
18
|
+
- `requests`
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install nr-log-handler
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Quick Start
|
|
27
|
+
|
|
28
|
+
### Standalone logger
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from newrelic_logger import NewRelicLogger
|
|
32
|
+
|
|
33
|
+
logger = NewRelicLogger(
|
|
34
|
+
api_key="YOUR_NEW_RELIC_LICENSE_KEY",
|
|
35
|
+
region="us", # "us" (default) or "eu"
|
|
36
|
+
mode="async", # "sync" (default) or "async"
|
|
37
|
+
attributes={"service": "my-app", "environment": "production"},
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
logger.info("Application started")
|
|
41
|
+
logger.error("Something went wrong", extra_attributes={"request_id": "abc-123"})
|
|
42
|
+
|
|
43
|
+
# Always call close() on shutdown to flush buffered logs
|
|
44
|
+
logger.close()
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### As a `logging.Handler`
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
import logging
|
|
51
|
+
from newrelic_logger import NewRelicHandler
|
|
52
|
+
|
|
53
|
+
handler = NewRelicHandler(
|
|
54
|
+
api_key="YOUR_NEW_RELIC_LICENSE_KEY",
|
|
55
|
+
region="us",
|
|
56
|
+
mode="sync",
|
|
57
|
+
attributes={"service": "my-app"},
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
logging.basicConfig(level=logging.INFO)
|
|
61
|
+
logger = logging.getLogger("myapp")
|
|
62
|
+
logger.addHandler(handler)
|
|
63
|
+
|
|
64
|
+
logger.info("Hello from standard logging")
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Configuration
|
|
68
|
+
|
|
69
|
+
| Parameter | Type | Default | Description |
|
|
70
|
+
|---|---|---|---|
|
|
71
|
+
| `api_key` | `str` | **required** | New Relic license key |
|
|
72
|
+
| `region` | `str` | `"us"` | `"us"` or `"eu"` |
|
|
73
|
+
| `mode` | `str` | `"sync"` | `"sync"` or `"async"` |
|
|
74
|
+
| `batch_size` | `int` | `100` | Max logs per batch (async only) |
|
|
75
|
+
| `flush_interval` | `float` | `5.0` | Seconds between flushes (async only) |
|
|
76
|
+
| `timeout` | `int` | `10` | HTTP timeout in seconds |
|
|
77
|
+
| `max_retries` | `int` | `5` | Max retry attempts on transient failure |
|
|
78
|
+
| `backoff_factor` | `float` | `0.5` | Exponential backoff multiplier |
|
|
79
|
+
| `attributes` | `dict` | `None` | Global attributes on every log entry |
|
|
80
|
+
|
|
81
|
+
## Error Handling
|
|
82
|
+
|
|
83
|
+
The library **never raises exceptions** from logging calls. On failure it retries up to `max_retries` times with exponential backoff, then emits a warning via Python's stdlib `logging` to the `newrelic_logger` logger and silently drops the batch.
|
|
84
|
+
|
|
85
|
+
To capture these internal warnings:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
import logging
|
|
89
|
+
logging.getLogger("newrelic_logger").setLevel(logging.WARNING)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## License
|
|
93
|
+
|
|
94
|
+
MIT
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "nr-log-handler"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Send logs to New Relic via their Log REST API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
authors = [{ name = "Your Name", email = "you@example.com" }]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
dependencies = ["requests>=2.28"]
|
|
14
|
+
keywords = ["newrelic", "logging", "observability"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Topic :: System :: Logging",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/yourname/newrelic-logger"
|
|
29
|
+
Repository = "https://github.com/yourname/newrelic-logger"
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
dev = ["pytest>=7.4", "pytest-mock>=3.11"]
|
|
33
|
+
|
|
34
|
+
[tool.setuptools.packages.find]
|
|
35
|
+
where = ["src"]
|
|
36
|
+
|
|
37
|
+
[tool.pytest.ini_options]
|
|
38
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from newrelic_logger.handler import NewRelicHandler
|
|
2
|
+
from newrelic_logger.logger import NewRelicLogger
|
|
3
|
+
from newrelic_logger.exceptions import NewRelicLoggerError, ConfigurationError
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"NewRelicHandler",
|
|
7
|
+
"NewRelicLogger",
|
|
8
|
+
"NewRelicLoggerError",
|
|
9
|
+
"ConfigurationError",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import queue
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from newrelic_logger.client import NewRelicClient
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BatchQueue:
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
client: NewRelicClient,
|
|
16
|
+
batch_size: int = 100,
|
|
17
|
+
flush_interval: float = 5.0,
|
|
18
|
+
common_attributes: dict | None = None,
|
|
19
|
+
) -> None:
|
|
20
|
+
self._client = client
|
|
21
|
+
self._batch_size = batch_size
|
|
22
|
+
self._flush_interval = flush_interval
|
|
23
|
+
self._common_attributes = common_attributes
|
|
24
|
+
self._queue: queue.Queue[dict] = queue.Queue()
|
|
25
|
+
self._lock = threading.Lock()
|
|
26
|
+
self._stop_event = threading.Event()
|
|
27
|
+
self._thread = threading.Thread(target=self._run, daemon=True)
|
|
28
|
+
self._thread.start()
|
|
29
|
+
|
|
30
|
+
def put(self, log: dict) -> None:
|
|
31
|
+
self._queue.put(log)
|
|
32
|
+
if self._queue.qsize() >= self._batch_size:
|
|
33
|
+
self._flush()
|
|
34
|
+
|
|
35
|
+
def _run(self) -> None:
|
|
36
|
+
last_flush = time.monotonic()
|
|
37
|
+
while not self._stop_event.is_set():
|
|
38
|
+
elapsed = time.monotonic() - last_flush
|
|
39
|
+
if elapsed >= self._flush_interval:
|
|
40
|
+
self._flush()
|
|
41
|
+
last_flush = time.monotonic()
|
|
42
|
+
time.sleep(0.05)
|
|
43
|
+
|
|
44
|
+
def _flush(self) -> None:
|
|
45
|
+
with self._lock:
|
|
46
|
+
batch: list[dict] = []
|
|
47
|
+
try:
|
|
48
|
+
while True:
|
|
49
|
+
batch.append(self._queue.get_nowait())
|
|
50
|
+
except queue.Empty:
|
|
51
|
+
pass
|
|
52
|
+
if batch:
|
|
53
|
+
self._client.send(batch, common_attributes=self._common_attributes)
|
|
54
|
+
|
|
55
|
+
def flush(self) -> None:
|
|
56
|
+
self._flush()
|
|
57
|
+
|
|
58
|
+
def close(self) -> None:
|
|
59
|
+
self._stop_event.set()
|
|
60
|
+
self._thread.join(timeout=5.0)
|
|
61
|
+
self._flush()
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
from newrelic_logger.exceptions import ConfigurationError
|
|
10
|
+
|
|
11
|
+
_ENDPOINTS: dict[str, str] = {
|
|
12
|
+
"us": "https://log-api.newrelic.com/log/v1",
|
|
13
|
+
"eu": "https://log-api.eu.newrelic.com/log/v1",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
_internal_logger = logging.getLogger("newrelic_logger")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class NewRelicClient:
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
api_key: str,
|
|
23
|
+
region: str = "us",
|
|
24
|
+
timeout: int = 10,
|
|
25
|
+
max_retries: int = 5,
|
|
26
|
+
backoff_factor: float = 0.5,
|
|
27
|
+
) -> None:
|
|
28
|
+
if not api_key:
|
|
29
|
+
raise ConfigurationError("api_key is required and must not be empty.")
|
|
30
|
+
if region not in _ENDPOINTS:
|
|
31
|
+
raise ConfigurationError(f"Invalid region '{region}'. Must be one of: {set(_ENDPOINTS)}")
|
|
32
|
+
self._api_key = api_key
|
|
33
|
+
self._endpoint = _ENDPOINTS[region]
|
|
34
|
+
self._timeout = timeout
|
|
35
|
+
self._max_retries = max_retries
|
|
36
|
+
self._backoff_factor = backoff_factor
|
|
37
|
+
|
|
38
|
+
def send(self, logs: list[dict], common_attributes: dict | None = None) -> None:
|
|
39
|
+
entry: dict = {"logs": logs}
|
|
40
|
+
if common_attributes:
|
|
41
|
+
entry["common"] = {"attributes": common_attributes}
|
|
42
|
+
payload = json.dumps([entry])
|
|
43
|
+
headers = {
|
|
44
|
+
"Api-Key": self._api_key,
|
|
45
|
+
"Content-Type": "application/json",
|
|
46
|
+
}
|
|
47
|
+
self._post_with_retry(payload, headers, logs)
|
|
48
|
+
|
|
49
|
+
def _post_with_retry(self, payload: str, headers: dict, logs: list[dict]) -> None:
|
|
50
|
+
last_exc: Exception | None = None
|
|
51
|
+
for attempt in range(self._max_retries + 1):
|
|
52
|
+
if attempt > 0:
|
|
53
|
+
wait = self._backoff_factor * (2 ** (attempt - 1))
|
|
54
|
+
time.sleep(wait)
|
|
55
|
+
try:
|
|
56
|
+
response = requests.post(
|
|
57
|
+
self._endpoint, data=payload, headers=headers, timeout=self._timeout
|
|
58
|
+
)
|
|
59
|
+
if response.ok:
|
|
60
|
+
return
|
|
61
|
+
if response.status_code in (400, 403):
|
|
62
|
+
_internal_logger.warning(
|
|
63
|
+
"newrelic_logger: Permanent failure sending %d log(s) — HTTP %d: %s",
|
|
64
|
+
len(logs),
|
|
65
|
+
response.status_code,
|
|
66
|
+
response.text,
|
|
67
|
+
)
|
|
68
|
+
return
|
|
69
|
+
last_exc = RuntimeError(f"HTTP {response.status_code}: {response.text}")
|
|
70
|
+
except requests.RequestException as exc:
|
|
71
|
+
last_exc = exc
|
|
72
|
+
|
|
73
|
+
_internal_logger.warning(
|
|
74
|
+
"newrelic_logger: Failed to send %d log(s) to New Relic after %d retries: %s",
|
|
75
|
+
len(logs),
|
|
76
|
+
self._max_retries,
|
|
77
|
+
str(last_exc),
|
|
78
|
+
)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from newrelic_logger.client import NewRelicClient
|
|
6
|
+
from newrelic_logger.batch import BatchQueue
|
|
7
|
+
from newrelic_logger.exceptions import ConfigurationError
|
|
8
|
+
|
|
9
|
+
_VALID_REGIONS = {"us", "eu"}
|
|
10
|
+
_VALID_MODES = {"sync", "async"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class NewRelicHandler(logging.Handler):
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
api_key: str = "",
|
|
17
|
+
region: str = "us",
|
|
18
|
+
mode: str = "sync",
|
|
19
|
+
batch_size: int = 100,
|
|
20
|
+
flush_interval: float = 5.0,
|
|
21
|
+
timeout: int = 10,
|
|
22
|
+
max_retries: int = 5,
|
|
23
|
+
backoff_factor: float = 0.5,
|
|
24
|
+
attributes: dict | None = None,
|
|
25
|
+
level: int = logging.NOTSET,
|
|
26
|
+
) -> None:
|
|
27
|
+
if not api_key:
|
|
28
|
+
raise ConfigurationError("api_key is required and must not be empty.")
|
|
29
|
+
if region not in _VALID_REGIONS:
|
|
30
|
+
raise ConfigurationError(f"Invalid region '{region}'. Must be one of: {_VALID_REGIONS}")
|
|
31
|
+
if mode not in _VALID_MODES:
|
|
32
|
+
raise ConfigurationError(f"Invalid mode '{mode}'. Must be one of: {_VALID_MODES}")
|
|
33
|
+
|
|
34
|
+
super().__init__(level)
|
|
35
|
+
self._global_attributes: dict = attributes or {}
|
|
36
|
+
self._mode = mode
|
|
37
|
+
self._client = NewRelicClient(
|
|
38
|
+
api_key=api_key,
|
|
39
|
+
region=region,
|
|
40
|
+
timeout=timeout,
|
|
41
|
+
max_retries=max_retries,
|
|
42
|
+
backoff_factor=backoff_factor,
|
|
43
|
+
)
|
|
44
|
+
self._batch_queue: BatchQueue | None = None
|
|
45
|
+
if mode == "async":
|
|
46
|
+
self._batch_queue = BatchQueue(
|
|
47
|
+
client=self._client,
|
|
48
|
+
batch_size=batch_size,
|
|
49
|
+
flush_interval=flush_interval,
|
|
50
|
+
common_attributes=self._global_attributes or None,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
54
|
+
try:
|
|
55
|
+
extra_attrs: dict = getattr(record, "extra_attributes", {}) or {}
|
|
56
|
+
log_dict = {
|
|
57
|
+
"timestamp": int(record.created * 1000),
|
|
58
|
+
"message": self.format(record) if self.formatter else record.getMessage(),
|
|
59
|
+
"level": record.levelname,
|
|
60
|
+
"attributes": extra_attrs,
|
|
61
|
+
}
|
|
62
|
+
if self._mode == "async" and self._batch_queue is not None:
|
|
63
|
+
self._batch_queue.put(log_dict)
|
|
64
|
+
else:
|
|
65
|
+
self._client.send([log_dict], common_attributes=self._global_attributes or None)
|
|
66
|
+
except Exception:
|
|
67
|
+
self.handleError(record)
|
|
68
|
+
|
|
69
|
+
def close(self) -> None:
|
|
70
|
+
if self._batch_queue is not None:
|
|
71
|
+
self._batch_queue.close()
|
|
72
|
+
super().close()
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import uuid
|
|
5
|
+
|
|
6
|
+
from newrelic_logger.handler import NewRelicHandler
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class NewRelicLogger:
|
|
10
|
+
def __init__(
|
|
11
|
+
self,
|
|
12
|
+
api_key: str = "",
|
|
13
|
+
region: str = "us",
|
|
14
|
+
mode: str = "sync",
|
|
15
|
+
batch_size: int = 100,
|
|
16
|
+
flush_interval: float = 5.0,
|
|
17
|
+
timeout: int = 10,
|
|
18
|
+
max_retries: int = 5,
|
|
19
|
+
backoff_factor: float = 0.5,
|
|
20
|
+
attributes: dict | None = None,
|
|
21
|
+
) -> None:
|
|
22
|
+
self._handler = NewRelicHandler(
|
|
23
|
+
api_key=api_key,
|
|
24
|
+
region=region,
|
|
25
|
+
mode=mode,
|
|
26
|
+
batch_size=batch_size,
|
|
27
|
+
flush_interval=flush_interval,
|
|
28
|
+
timeout=timeout,
|
|
29
|
+
max_retries=max_retries,
|
|
30
|
+
backoff_factor=backoff_factor,
|
|
31
|
+
attributes=attributes,
|
|
32
|
+
)
|
|
33
|
+
# Use a unique logger name to avoid collisions between instances
|
|
34
|
+
self._logger = logging.getLogger(f"newrelic_logger.user.{uuid.uuid4().hex}")
|
|
35
|
+
self._logger.addHandler(self._handler)
|
|
36
|
+
self._logger.setLevel(logging.DEBUG)
|
|
37
|
+
self._logger.propagate = False
|
|
38
|
+
|
|
39
|
+
def _log(self, level: int, msg: str, extra_attributes: dict | None = None) -> None:
|
|
40
|
+
extra = {"extra_attributes": extra_attributes or {}}
|
|
41
|
+
self._logger.log(level, msg, extra=extra)
|
|
42
|
+
|
|
43
|
+
def debug(self, msg: str, extra_attributes: dict | None = None) -> None:
|
|
44
|
+
self._log(logging.DEBUG, msg, extra_attributes)
|
|
45
|
+
|
|
46
|
+
def info(self, msg: str, extra_attributes: dict | None = None) -> None:
|
|
47
|
+
self._log(logging.INFO, msg, extra_attributes)
|
|
48
|
+
|
|
49
|
+
def warning(self, msg: str, extra_attributes: dict | None = None) -> None:
|
|
50
|
+
self._log(logging.WARNING, msg, extra_attributes)
|
|
51
|
+
|
|
52
|
+
def error(self, msg: str, extra_attributes: dict | None = None) -> None:
|
|
53
|
+
self._log(logging.ERROR, msg, extra_attributes)
|
|
54
|
+
|
|
55
|
+
def critical(self, msg: str, extra_attributes: dict | None = None) -> None:
|
|
56
|
+
self._log(logging.CRITICAL, msg, extra_attributes)
|
|
57
|
+
|
|
58
|
+
def close(self) -> None:
|
|
59
|
+
self._handler.close()
|
|
60
|
+
self._logger.removeHandler(self._handler)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nr-log-handler
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Send logs to New Relic via their Log REST API
|
|
5
|
+
Author-email: Your Name <you@example.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/yourname/newrelic-logger
|
|
8
|
+
Project-URL: Repository, https://github.com/yourname/newrelic-logger
|
|
9
|
+
Keywords: newrelic,logging,observability
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
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: Topic :: System :: Logging
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Requires-Dist: requests>=2.28
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=7.4; extra == "dev"
|
|
24
|
+
Requires-Dist: pytest-mock>=3.11; extra == "dev"
|
|
25
|
+
|
|
26
|
+
# nr-log-handler
|
|
27
|
+
|
|
28
|
+
A lightweight Python library for sending logs to [New Relic](https://newrelic.com) via their [Log API](https://docs.newrelic.com/docs/logs/log-api/introduction-log-api/). No New Relic agent required — pure REST API.
|
|
29
|
+
|
|
30
|
+
## Features
|
|
31
|
+
|
|
32
|
+
- Works with Python's built-in `logging` module as a drop-in `logging.Handler`
|
|
33
|
+
- Standalone `NewRelicLogger` with `.info()`, `.error()` etc.
|
|
34
|
+
- US and EU region support
|
|
35
|
+
- Sync (immediate) and async (batched, background thread) modes
|
|
36
|
+
- Configurable batch size and flush interval
|
|
37
|
+
- Exponential backoff retry — never crashes your application
|
|
38
|
+
- Global and per-call custom attributes
|
|
39
|
+
|
|
40
|
+
## Requirements
|
|
41
|
+
|
|
42
|
+
- Python 3.10+
|
|
43
|
+
- `requests`
|
|
44
|
+
|
|
45
|
+
## Installation
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install nr-log-handler
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Quick Start
|
|
52
|
+
|
|
53
|
+
### Standalone logger
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from newrelic_logger import NewRelicLogger
|
|
57
|
+
|
|
58
|
+
logger = NewRelicLogger(
|
|
59
|
+
api_key="YOUR_NEW_RELIC_LICENSE_KEY",
|
|
60
|
+
region="us", # "us" (default) or "eu"
|
|
61
|
+
mode="async", # "sync" (default) or "async"
|
|
62
|
+
attributes={"service": "my-app", "environment": "production"},
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
logger.info("Application started")
|
|
66
|
+
logger.error("Something went wrong", extra_attributes={"request_id": "abc-123"})
|
|
67
|
+
|
|
68
|
+
# Always call close() on shutdown to flush buffered logs
|
|
69
|
+
logger.close()
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### As a `logging.Handler`
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
import logging
|
|
76
|
+
from newrelic_logger import NewRelicHandler
|
|
77
|
+
|
|
78
|
+
handler = NewRelicHandler(
|
|
79
|
+
api_key="YOUR_NEW_RELIC_LICENSE_KEY",
|
|
80
|
+
region="us",
|
|
81
|
+
mode="sync",
|
|
82
|
+
attributes={"service": "my-app"},
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
logging.basicConfig(level=logging.INFO)
|
|
86
|
+
logger = logging.getLogger("myapp")
|
|
87
|
+
logger.addHandler(handler)
|
|
88
|
+
|
|
89
|
+
logger.info("Hello from standard logging")
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Configuration
|
|
93
|
+
|
|
94
|
+
| Parameter | Type | Default | Description |
|
|
95
|
+
|---|---|---|---|
|
|
96
|
+
| `api_key` | `str` | **required** | New Relic license key |
|
|
97
|
+
| `region` | `str` | `"us"` | `"us"` or `"eu"` |
|
|
98
|
+
| `mode` | `str` | `"sync"` | `"sync"` or `"async"` |
|
|
99
|
+
| `batch_size` | `int` | `100` | Max logs per batch (async only) |
|
|
100
|
+
| `flush_interval` | `float` | `5.0` | Seconds between flushes (async only) |
|
|
101
|
+
| `timeout` | `int` | `10` | HTTP timeout in seconds |
|
|
102
|
+
| `max_retries` | `int` | `5` | Max retry attempts on transient failure |
|
|
103
|
+
| `backoff_factor` | `float` | `0.5` | Exponential backoff multiplier |
|
|
104
|
+
| `attributes` | `dict` | `None` | Global attributes on every log entry |
|
|
105
|
+
|
|
106
|
+
## Error Handling
|
|
107
|
+
|
|
108
|
+
The library **never raises exceptions** from logging calls. On failure it retries up to `max_retries` times with exponential backoff, then emits a warning via Python's stdlib `logging` to the `newrelic_logger` logger and silently drops the batch.
|
|
109
|
+
|
|
110
|
+
To capture these internal warnings:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
import logging
|
|
114
|
+
logging.getLogger("newrelic_logger").setLevel(logging.WARNING)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## License
|
|
118
|
+
|
|
119
|
+
MIT
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/newrelic_logger/__init__.py
|
|
4
|
+
src/newrelic_logger/batch.py
|
|
5
|
+
src/newrelic_logger/client.py
|
|
6
|
+
src/newrelic_logger/exceptions.py
|
|
7
|
+
src/newrelic_logger/handler.py
|
|
8
|
+
src/newrelic_logger/logger.py
|
|
9
|
+
src/nr_log_handler.egg-info/PKG-INFO
|
|
10
|
+
src/nr_log_handler.egg-info/SOURCES.txt
|
|
11
|
+
src/nr_log_handler.egg-info/dependency_links.txt
|
|
12
|
+
src/nr_log_handler.egg-info/requires.txt
|
|
13
|
+
src/nr_log_handler.egg-info/top_level.txt
|
|
14
|
+
tests/test_batch.py
|
|
15
|
+
tests/test_client.py
|
|
16
|
+
tests/test_exceptions.py
|
|
17
|
+
tests/test_handler.py
|
|
18
|
+
tests/test_logger.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
newrelic_logger
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import threading
|
|
3
|
+
from unittest.mock import MagicMock, patch
|
|
4
|
+
from newrelic_logger.batch import BatchQueue
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def make_client():
|
|
8
|
+
client = MagicMock()
|
|
9
|
+
client.send = MagicMock()
|
|
10
|
+
return client
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_flush_on_batch_size():
|
|
14
|
+
client = make_client()
|
|
15
|
+
q = BatchQueue(client=client, batch_size=3, flush_interval=60.0)
|
|
16
|
+
try:
|
|
17
|
+
log = {"timestamp": 1000, "message": "x", "level": "INFO", "attributes": {}}
|
|
18
|
+
q.put(log)
|
|
19
|
+
q.put(log)
|
|
20
|
+
q.put(log) # triggers flush
|
|
21
|
+
time.sleep(0.1)
|
|
22
|
+
client.send.assert_called_once()
|
|
23
|
+
args = client.send.call_args[0][0]
|
|
24
|
+
assert len(args) == 3
|
|
25
|
+
finally:
|
|
26
|
+
q.close()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_flush_on_interval():
|
|
30
|
+
client = make_client()
|
|
31
|
+
q = BatchQueue(client=client, batch_size=100, flush_interval=0.1)
|
|
32
|
+
try:
|
|
33
|
+
log = {"timestamp": 1000, "message": "x", "level": "INFO", "attributes": {}}
|
|
34
|
+
q.put(log)
|
|
35
|
+
time.sleep(1.0) # increased from 0.4 to 1.0 for CI reliability
|
|
36
|
+
client.send.assert_called_once()
|
|
37
|
+
finally:
|
|
38
|
+
q.close()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_close_flushes_remaining():
|
|
42
|
+
client = make_client()
|
|
43
|
+
q = BatchQueue(client=client, batch_size=100, flush_interval=60.0)
|
|
44
|
+
log = {"timestamp": 1000, "message": "x", "level": "INFO", "attributes": {}}
|
|
45
|
+
q.put(log)
|
|
46
|
+
q.put(log)
|
|
47
|
+
q.close()
|
|
48
|
+
client.send.assert_called_once()
|
|
49
|
+
args = client.send.call_args[0][0]
|
|
50
|
+
assert len(args) == 2
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_thread_safe_concurrent_puts():
|
|
54
|
+
client = make_client()
|
|
55
|
+
q = BatchQueue(client=client, batch_size=1000, flush_interval=60.0)
|
|
56
|
+
log = {"timestamp": 1000, "message": "x", "level": "INFO", "attributes": {}}
|
|
57
|
+
threads = [threading.Thread(target=q.put, args=(log,)) for _ in range(50)]
|
|
58
|
+
for t in threads:
|
|
59
|
+
t.start()
|
|
60
|
+
for t in threads:
|
|
61
|
+
t.join()
|
|
62
|
+
q.close()
|
|
63
|
+
total_sent = sum(len(call[0][0]) for call in client.send.call_args_list)
|
|
64
|
+
assert total_sent == 50
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import pytest
|
|
3
|
+
import requests
|
|
4
|
+
from unittest.mock import patch, MagicMock
|
|
5
|
+
from newrelic_logger.client import NewRelicClient
|
|
6
|
+
from newrelic_logger.exceptions import ConfigurationError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.fixture
|
|
10
|
+
def client():
|
|
11
|
+
return NewRelicClient(api_key="test-key", region="us")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_send_posts_to_us_endpoint(client):
|
|
15
|
+
logs = [{"timestamp": 1000, "message": "hello", "level": "INFO", "attributes": {}}]
|
|
16
|
+
with patch("newrelic_logger.client.requests.post") as mock_post:
|
|
17
|
+
mock_post.return_value = MagicMock(status_code=202, ok=True)
|
|
18
|
+
client.send(logs)
|
|
19
|
+
|
|
20
|
+
mock_post.assert_called_once()
|
|
21
|
+
url = mock_post.call_args.args[0]
|
|
22
|
+
assert url == "https://log-api.newrelic.com/log/v1"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_send_posts_to_eu_endpoint():
|
|
26
|
+
eu_client = NewRelicClient(api_key="test-key", region="eu")
|
|
27
|
+
logs = [{"timestamp": 1000, "message": "hello", "level": "INFO", "attributes": {}}]
|
|
28
|
+
with patch("newrelic_logger.client.requests.post") as mock_post:
|
|
29
|
+
mock_post.return_value = MagicMock(status_code=202, ok=True)
|
|
30
|
+
eu_client.send(logs)
|
|
31
|
+
|
|
32
|
+
url = mock_post.call_args.args[0]
|
|
33
|
+
assert url == "https://log-api.eu.newrelic.com/log/v1"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_send_sets_api_key_header(client):
|
|
37
|
+
logs = [{"timestamp": 1000, "message": "hello", "level": "INFO", "attributes": {}}]
|
|
38
|
+
with patch("newrelic_logger.client.requests.post") as mock_post:
|
|
39
|
+
mock_post.return_value = MagicMock(status_code=202, ok=True)
|
|
40
|
+
client.send(logs)
|
|
41
|
+
|
|
42
|
+
headers = mock_post.call_args.kwargs["headers"]
|
|
43
|
+
assert headers["Api-Key"] == "test-key"
|
|
44
|
+
assert headers["Content-Type"] == "application/json"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_send_builds_correct_payload(client):
|
|
48
|
+
logs = [
|
|
49
|
+
{"timestamp": 1711000000000, "message": "test msg", "level": "ERROR", "attributes": {"x": "1"}},
|
|
50
|
+
]
|
|
51
|
+
with patch("newrelic_logger.client.requests.post") as mock_post:
|
|
52
|
+
mock_post.return_value = MagicMock(status_code=202, ok=True)
|
|
53
|
+
client.send(logs)
|
|
54
|
+
|
|
55
|
+
payload = json.loads(mock_post.call_args.kwargs["data"])
|
|
56
|
+
assert payload[0]["logs"][0]["message"] == "test msg"
|
|
57
|
+
assert payload[0]["logs"][0]["level"] == "ERROR"
|
|
58
|
+
assert payload[0]["logs"][0]["timestamp"] == 1711000000000
|
|
59
|
+
assert payload[0]["logs"][0]["attributes"] == {"x": "1"}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_send_includes_common_attributes_in_payload(client):
|
|
63
|
+
logs = [{"timestamp": 1000, "message": "test", "level": "INFO", "attributes": {}}]
|
|
64
|
+
with patch("newrelic_logger.client.requests.post") as mock_post:
|
|
65
|
+
mock_post.return_value = MagicMock(status_code=202, ok=True)
|
|
66
|
+
client.send(logs, common_attributes={"service": "svc", "env": "prod"})
|
|
67
|
+
|
|
68
|
+
payload = json.loads(mock_post.call_args.kwargs["data"])
|
|
69
|
+
assert payload[0]["common"]["attributes"]["service"] == "svc"
|
|
70
|
+
assert payload[0]["common"]["attributes"]["env"] == "prod"
|
|
71
|
+
assert payload[0]["logs"][0]["message"] == "test"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_send_without_common_attributes_omits_common_block(client):
|
|
75
|
+
logs = [{"timestamp": 1000, "message": "test", "level": "INFO", "attributes": {}}]
|
|
76
|
+
with patch("newrelic_logger.client.requests.post") as mock_post:
|
|
77
|
+
mock_post.return_value = MagicMock(status_code=202, ok=True)
|
|
78
|
+
client.send(logs)
|
|
79
|
+
|
|
80
|
+
payload = json.loads(mock_post.call_args.kwargs["data"])
|
|
81
|
+
assert "common" not in payload[0]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_raises_on_invalid_region():
|
|
85
|
+
with pytest.raises(ConfigurationError, match="region"):
|
|
86
|
+
NewRelicClient(api_key="key", region="ap")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_raises_on_empty_api_key():
|
|
90
|
+
with pytest.raises(ConfigurationError, match="api_key"):
|
|
91
|
+
NewRelicClient(api_key="")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_retries_on_429(client):
|
|
95
|
+
logs = [{"timestamp": 1000, "message": "x", "level": "INFO", "attributes": {}}]
|
|
96
|
+
responses = [MagicMock(status_code=429, ok=False, text="rate limit")] * 5 + [
|
|
97
|
+
MagicMock(status_code=202, ok=True)
|
|
98
|
+
]
|
|
99
|
+
with patch("newrelic_logger.client.requests.post", side_effect=responses) as mock_post:
|
|
100
|
+
with patch("newrelic_logger.client.time.sleep"):
|
|
101
|
+
client.send(logs)
|
|
102
|
+
assert mock_post.call_count == 6
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_retries_on_500(client):
|
|
106
|
+
logs = [{"timestamp": 1000, "message": "x", "level": "INFO", "attributes": {}}]
|
|
107
|
+
responses = [MagicMock(status_code=500, ok=False, text="err")] * 5 + [
|
|
108
|
+
MagicMock(status_code=202, ok=True)
|
|
109
|
+
]
|
|
110
|
+
with patch("newrelic_logger.client.requests.post", side_effect=responses) as mock_post:
|
|
111
|
+
with patch("newrelic_logger.client.time.sleep"):
|
|
112
|
+
client.send(logs)
|
|
113
|
+
assert mock_post.call_count == 6
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_no_retry_on_400(client):
|
|
117
|
+
logs = [{"timestamp": 1000, "message": "x", "level": "INFO", "attributes": {}}]
|
|
118
|
+
with patch("newrelic_logger.client.requests.post") as mock_post:
|
|
119
|
+
mock_post.return_value = MagicMock(status_code=400, ok=False, text="bad")
|
|
120
|
+
client.send(logs)
|
|
121
|
+
assert mock_post.call_count == 1
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_no_retry_on_403(client):
|
|
125
|
+
logs = [{"timestamp": 1000, "message": "x", "level": "INFO", "attributes": {}}]
|
|
126
|
+
with patch("newrelic_logger.client.requests.post") as mock_post:
|
|
127
|
+
mock_post.return_value = MagicMock(status_code=403, ok=False, text="forbidden")
|
|
128
|
+
client.send(logs)
|
|
129
|
+
assert mock_post.call_count == 1
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_warns_after_exhausting_retries(client, caplog):
|
|
133
|
+
import logging as stdlib_logging
|
|
134
|
+
logs = [{"timestamp": 1000, "message": "x", "level": "INFO", "attributes": {}}]
|
|
135
|
+
with patch("newrelic_logger.client.requests.post") as mock_post:
|
|
136
|
+
mock_post.return_value = MagicMock(status_code=500, ok=False, text="err")
|
|
137
|
+
with patch("newrelic_logger.client.time.sleep"):
|
|
138
|
+
with caplog.at_level(stdlib_logging.WARNING, logger="newrelic_logger"):
|
|
139
|
+
client.send(logs)
|
|
140
|
+
assert "Failed to send" in caplog.text
|
|
141
|
+
assert mock_post.call_count == 6 # 1 initial + 5 retries
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_retries_on_connection_error(client):
|
|
145
|
+
logs = [{"timestamp": 1000, "message": "x", "level": "INFO", "attributes": {}}]
|
|
146
|
+
with patch("newrelic_logger.client.requests.post") as mock_post:
|
|
147
|
+
mock_post.side_effect = requests.ConnectionError("no connection")
|
|
148
|
+
with patch("newrelic_logger.client.time.sleep"):
|
|
149
|
+
client.send(logs)
|
|
150
|
+
assert mock_post.call_count == 6 # 1 initial + 5 retries
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from newrelic_logger.exceptions import NewRelicLoggerError, ConfigurationError
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_configuration_error_is_newrelic_logger_error():
|
|
5
|
+
err = ConfigurationError("bad config")
|
|
6
|
+
assert isinstance(err, NewRelicLoggerError)
|
|
7
|
+
assert str(err) == "bad config"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_newrelic_logger_error_is_exception():
|
|
11
|
+
err = NewRelicLoggerError("base")
|
|
12
|
+
assert isinstance(err, Exception)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import pytest
|
|
3
|
+
from unittest.mock import MagicMock, patch
|
|
4
|
+
from newrelic_logger.handler import NewRelicHandler
|
|
5
|
+
from newrelic_logger.exceptions import ConfigurationError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_raises_if_no_api_key():
|
|
9
|
+
with pytest.raises(ConfigurationError, match="api_key"):
|
|
10
|
+
NewRelicHandler(api_key="")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_raises_on_invalid_region():
|
|
14
|
+
with pytest.raises(ConfigurationError, match="region"):
|
|
15
|
+
NewRelicHandler(api_key="key", region="ap")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_raises_on_invalid_mode():
|
|
19
|
+
with pytest.raises(ConfigurationError, match="mode"):
|
|
20
|
+
NewRelicHandler(api_key="key", mode="streaming")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_sync_mode_calls_client_send():
|
|
24
|
+
handler = NewRelicHandler(api_key="key", mode="sync")
|
|
25
|
+
handler._client = MagicMock()
|
|
26
|
+
record = logging.LogRecord(
|
|
27
|
+
name="test", level=logging.INFO, pathname="", lineno=0,
|
|
28
|
+
msg="hello world", args=(), exc_info=None,
|
|
29
|
+
)
|
|
30
|
+
handler.emit(record)
|
|
31
|
+
handler._client.send.assert_called_once()
|
|
32
|
+
sent = handler._client.send.call_args[0][0]
|
|
33
|
+
assert sent[0]["message"] == "hello world"
|
|
34
|
+
assert sent[0]["level"] == "INFO"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_async_mode_puts_to_batch_queue():
|
|
38
|
+
handler = NewRelicHandler(api_key="key", mode="async")
|
|
39
|
+
handler._batch_queue = MagicMock()
|
|
40
|
+
record = logging.LogRecord(
|
|
41
|
+
name="test", level=logging.ERROR, pathname="", lineno=0,
|
|
42
|
+
msg="async log", args=(), exc_info=None,
|
|
43
|
+
)
|
|
44
|
+
handler.emit(record)
|
|
45
|
+
handler._batch_queue.put.assert_called_once()
|
|
46
|
+
sent = handler._batch_queue.put.call_args[0][0]
|
|
47
|
+
assert sent["message"] == "async log"
|
|
48
|
+
assert sent["level"] == "ERROR"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_global_attributes_merged():
|
|
52
|
+
handler = NewRelicHandler(api_key="key", mode="sync", attributes={"service": "svc"})
|
|
53
|
+
handler._client = MagicMock()
|
|
54
|
+
record = logging.LogRecord(
|
|
55
|
+
name="test", level=logging.INFO, pathname="", lineno=0,
|
|
56
|
+
msg="msg", args=(), exc_info=None,
|
|
57
|
+
)
|
|
58
|
+
handler.emit(record)
|
|
59
|
+
# Global attrs now passed as common_attributes keyword arg
|
|
60
|
+
call_kwargs = handler._client.send.call_args.kwargs
|
|
61
|
+
assert call_kwargs["common_attributes"]["service"] == "svc"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_per_call_attributes_override_global():
|
|
65
|
+
handler = NewRelicHandler(api_key="key", mode="sync", attributes={"env": "prod"})
|
|
66
|
+
handler._client = MagicMock()
|
|
67
|
+
record = logging.LogRecord(
|
|
68
|
+
name="test", level=logging.INFO, pathname="", lineno=0,
|
|
69
|
+
msg="msg", args=(), exc_info=None,
|
|
70
|
+
)
|
|
71
|
+
record.extra_attributes = {"env": "staging", "req_id": "abc"}
|
|
72
|
+
handler.emit(record)
|
|
73
|
+
sent = handler._client.send.call_args[0][0]
|
|
74
|
+
call_kwargs = handler._client.send.call_args.kwargs
|
|
75
|
+
# per-call attrs in the log entry itself
|
|
76
|
+
assert sent[0]["attributes"]["env"] == "staging"
|
|
77
|
+
assert sent[0]["attributes"]["req_id"] == "abc"
|
|
78
|
+
# global attrs still in common_attributes
|
|
79
|
+
assert call_kwargs["common_attributes"]["env"] == "prod"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_close_calls_batch_queue_close():
|
|
83
|
+
handler = NewRelicHandler(api_key="key", mode="async")
|
|
84
|
+
handler._batch_queue = MagicMock()
|
|
85
|
+
handler.close()
|
|
86
|
+
handler._batch_queue.close.assert_called_once()
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import pytest
|
|
3
|
+
from unittest.mock import MagicMock
|
|
4
|
+
from newrelic_logger.logger import NewRelicLogger
|
|
5
|
+
from newrelic_logger.exceptions import ConfigurationError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def make_logger(**kwargs) -> NewRelicLogger:
|
|
9
|
+
logger = NewRelicLogger(api_key="test-key", **kwargs)
|
|
10
|
+
logger._handler._client = MagicMock()
|
|
11
|
+
return logger
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_raises_if_no_api_key():
|
|
15
|
+
with pytest.raises(ConfigurationError):
|
|
16
|
+
NewRelicLogger(api_key="")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_info_sends_info_level():
|
|
20
|
+
logger = make_logger()
|
|
21
|
+
logger.info("info message")
|
|
22
|
+
sent = logger._handler._client.send.call_args[0][0]
|
|
23
|
+
assert sent[0]["level"] == "INFO"
|
|
24
|
+
assert sent[0]["message"] == "info message"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_debug_sends_debug_level():
|
|
28
|
+
logger = make_logger()
|
|
29
|
+
logger.debug("debug message")
|
|
30
|
+
sent = logger._handler._client.send.call_args[0][0]
|
|
31
|
+
assert sent[0]["level"] == "DEBUG"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_warning_sends_warning_level():
|
|
35
|
+
logger = make_logger()
|
|
36
|
+
logger.warning("warn message")
|
|
37
|
+
sent = logger._handler._client.send.call_args[0][0]
|
|
38
|
+
assert sent[0]["level"] == "WARNING"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_error_sends_error_level():
|
|
42
|
+
logger = make_logger()
|
|
43
|
+
logger.error("error message")
|
|
44
|
+
sent = logger._handler._client.send.call_args[0][0]
|
|
45
|
+
assert sent[0]["level"] == "ERROR"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_critical_sends_critical_level():
|
|
49
|
+
logger = make_logger()
|
|
50
|
+
logger.critical("critical message")
|
|
51
|
+
sent = logger._handler._client.send.call_args[0][0]
|
|
52
|
+
assert sent[0]["level"] == "CRITICAL"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_extra_attributes_passed_through():
|
|
56
|
+
logger = make_logger(attributes={"service": "svc"})
|
|
57
|
+
logger.info("msg", extra_attributes={"req_id": "xyz"})
|
|
58
|
+
call_kwargs = logger._handler._client.send.call_args.kwargs
|
|
59
|
+
sent = logger._handler._client.send.call_args[0][0]
|
|
60
|
+
# global attr in common_attributes
|
|
61
|
+
assert call_kwargs["common_attributes"]["service"] == "svc"
|
|
62
|
+
# per-call attr in log entry attributes
|
|
63
|
+
assert sent[0]["attributes"]["req_id"] == "xyz"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_close_flushes_handler():
|
|
67
|
+
logger = make_logger(mode="async")
|
|
68
|
+
logger._handler._batch_queue = MagicMock()
|
|
69
|
+
logger.close()
|
|
70
|
+
logger._handler._batch_queue.close.assert_called_once()
|