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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,6 @@
1
+ class NewRelicLoggerError(Exception):
2
+ """Base exception for newrelic-logger."""
3
+
4
+
5
+ class ConfigurationError(NewRelicLoggerError):
6
+ """Raised when the handler/logger is misconfigured at init time."""
@@ -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,5 @@
1
+ requests>=2.28
2
+
3
+ [dev]
4
+ pytest>=7.4
5
+ pytest-mock>=3.11
@@ -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()