tailcove 0.1.0__py3-none-any.whl
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.
- tailcove/__init__.py +5 -0
- tailcove/context.py +25 -0
- tailcove/exceptions.py +40 -0
- tailcove/handler.py +160 -0
- tailcove/levels.py +22 -0
- tailcove/py.typed +0 -0
- tailcove/transport.py +51 -0
- tailcove-0.1.0.dist-info/METADATA +201 -0
- tailcove-0.1.0.dist-info/RECORD +11 -0
- tailcove-0.1.0.dist-info/WHEEL +4 -0
- tailcove-0.1.0.dist-info/licenses/LICENSE +21 -0
tailcove/__init__.py
ADDED
tailcove/context.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any, Dict, FrozenSet
|
|
5
|
+
|
|
6
|
+
# The stdlib `logging` module doesn't nest `extra={...}` under a key — each
|
|
7
|
+
# entry lands as a top-level attribute directly on the LogRecord. To recover
|
|
8
|
+
# "the extra dict" we diff a record's __dict__ against the attributes a
|
|
9
|
+
# vanilla LogRecord always carries; whatever's left over is caller-supplied
|
|
10
|
+
# context. Computed once from a throwaway record rather than hardcoded, so
|
|
11
|
+
# it stays correct if a future Python version adds/removes a built-in field.
|
|
12
|
+
_DUMMY_RECORD = logging.LogRecord(
|
|
13
|
+
name="tailcove",
|
|
14
|
+
level=logging.INFO,
|
|
15
|
+
pathname=__file__,
|
|
16
|
+
lineno=0,
|
|
17
|
+
msg="",
|
|
18
|
+
args=(),
|
|
19
|
+
exc_info=None,
|
|
20
|
+
)
|
|
21
|
+
RESERVED_ATTRS: FrozenSet[str] = frozenset(vars(_DUMMY_RECORD).keys()) | {"message", "asctime"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def extra_context(record: logging.LogRecord) -> Dict[str, Any]:
|
|
25
|
+
return {key: value for key, value in vars(record).items() if key not in RESERVED_ATTRS}
|
tailcove/exceptions.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import traceback
|
|
4
|
+
from types import TracebackType
|
|
5
|
+
from typing import Any, Dict, List, Optional, Tuple, Type
|
|
6
|
+
|
|
7
|
+
ExcInfo = Tuple[Optional[Type[BaseException]], Optional[BaseException], Optional[TracebackType]]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def format_exception(exc_info: ExcInfo, trace_limit: int = 10) -> Dict[str, Any]:
|
|
11
|
+
"""
|
|
12
|
+
Renders a caught exception as structured data instead of a flat string,
|
|
13
|
+
matching the tailcove/laravel package's `{class, message, location,
|
|
14
|
+
trace}` shape so fingerprinting (Milestone 11) works identically
|
|
15
|
+
regardless of which client sent the line. `location` is the file:line
|
|
16
|
+
of the frame where the exception was actually raised (the last frame in
|
|
17
|
+
the traceback); `trace` is trimmed to `trace_limit` frames the same way
|
|
18
|
+
the Laravel package trims its stringified trace.
|
|
19
|
+
"""
|
|
20
|
+
exc_type, exc_value, exc_tb = exc_info
|
|
21
|
+
|
|
22
|
+
frames = traceback.extract_tb(exc_tb)
|
|
23
|
+
location = f"{frames[-1].filename}:{frames[-1].lineno}" if frames else ""
|
|
24
|
+
|
|
25
|
+
trace: List[str] = [
|
|
26
|
+
f"{frame.filename}:{frame.lineno} in {frame.name}" for frame in frames
|
|
27
|
+
][:trace_limit]
|
|
28
|
+
|
|
29
|
+
class_name = "UnknownException"
|
|
30
|
+
if exc_type is not None:
|
|
31
|
+
class_name = exc_type.__qualname__
|
|
32
|
+
if exc_type.__module__ not in ("builtins", "__main__"):
|
|
33
|
+
class_name = f"{exc_type.__module__}.{class_name}"
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
"class": class_name,
|
|
37
|
+
"message": str(exc_value) if exc_value is not None else "",
|
|
38
|
+
"location": location,
|
|
39
|
+
"trace": trace,
|
|
40
|
+
}
|
tailcove/handler.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import queue
|
|
5
|
+
import socket
|
|
6
|
+
import sys
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from typing import Any, Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
from .context import extra_context
|
|
13
|
+
from .exceptions import format_exception
|
|
14
|
+
from .levels import normalize_level
|
|
15
|
+
from .transport import send_batch
|
|
16
|
+
|
|
17
|
+
DEFAULT_ENDPOINT = "https://ingest.tailcove.dev/api/v1/logs"
|
|
18
|
+
DEFAULT_BATCH_SIZE = 50
|
|
19
|
+
DEFAULT_BATCH_SECONDS = 2.0
|
|
20
|
+
DEFAULT_TIMEOUT = 2.0
|
|
21
|
+
DEFAULT_TRACE_LIMIT = 10
|
|
22
|
+
|
|
23
|
+
# Queue item kinds. A plain string tag plus payload, rather than subclasses,
|
|
24
|
+
# keeps the background loop's dispatch a single if/elif chain.
|
|
25
|
+
_LOG = "log"
|
|
26
|
+
_FLUSH = "flush"
|
|
27
|
+
_CLOSE = "close"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TailcoveHandler(logging.Handler):
|
|
31
|
+
"""
|
|
32
|
+
Batches records and ships them to a Tailcove source's ingest endpoint on
|
|
33
|
+
a background thread — up to `batch_size` lines or `batch_seconds`,
|
|
34
|
+
whichever comes first, matching the wire contract and thresholds of the
|
|
35
|
+
tailcove/laravel and @tailcove/node clients (see the log-ingestion
|
|
36
|
+
skill). Never raises into the host app: `emit()` catches its own
|
|
37
|
+
failures and reports them via `self.handleError()`, the stdlib
|
|
38
|
+
`Handler` convention; delivery failures on the background thread are
|
|
39
|
+
caught and printed to stderr rather than propagated anywhere.
|
|
40
|
+
|
|
41
|
+
Flushing at interpreter exit is handled by the stdlib itself: every
|
|
42
|
+
`logging.Handler` is registered in `logging._handlerList`, and the
|
|
43
|
+
`logging` module registers its own `atexit` hook (`logging.shutdown`)
|
|
44
|
+
that calls `flush()` then `close()` on every handler. Subclassing
|
|
45
|
+
`Handler` and implementing `close()` to drain the queue is therefore
|
|
46
|
+
sufficient — no separate `atexit.register()` call is needed here, and
|
|
47
|
+
adding one would risk a double flush racing the stdlib's own.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
token: str,
|
|
53
|
+
endpoint: str = DEFAULT_ENDPOINT,
|
|
54
|
+
batch_size: int = DEFAULT_BATCH_SIZE,
|
|
55
|
+
batch_seconds: float = DEFAULT_BATCH_SECONDS,
|
|
56
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
57
|
+
trace_limit: int = DEFAULT_TRACE_LIMIT,
|
|
58
|
+
level: int = logging.NOTSET,
|
|
59
|
+
) -> None:
|
|
60
|
+
super().__init__(level=level)
|
|
61
|
+
self.token = token
|
|
62
|
+
self.endpoint = endpoint
|
|
63
|
+
self.batch_size = batch_size
|
|
64
|
+
self.batch_seconds = batch_seconds
|
|
65
|
+
self.timeout = timeout
|
|
66
|
+
self.trace_limit = trace_limit
|
|
67
|
+
|
|
68
|
+
self._hostname = socket.gethostname()
|
|
69
|
+
self._queue: "queue.Queue[Any]" = queue.Queue()
|
|
70
|
+
self._closed = False
|
|
71
|
+
self._thread = threading.Thread(
|
|
72
|
+
target=self._run, name="tailcove-sender", daemon=True
|
|
73
|
+
)
|
|
74
|
+
self._thread.start()
|
|
75
|
+
|
|
76
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
77
|
+
try:
|
|
78
|
+
self._queue.put_nowait((_LOG, self._normalize(record)))
|
|
79
|
+
except Exception:
|
|
80
|
+
self.handleError(record)
|
|
81
|
+
|
|
82
|
+
def flush(self) -> None:
|
|
83
|
+
"""Sends whatever is buffered right now, regardless of size or age. Safe to call directly; blocks until the background thread confirms the flush."""
|
|
84
|
+
self._signal(_FLUSH)
|
|
85
|
+
|
|
86
|
+
def close(self) -> None:
|
|
87
|
+
if self._closed:
|
|
88
|
+
super().close()
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
self._closed = True
|
|
92
|
+
self._signal(_CLOSE)
|
|
93
|
+
self._thread.join(timeout=self.timeout + 1)
|
|
94
|
+
super().close()
|
|
95
|
+
|
|
96
|
+
def _signal(self, kind: str) -> None:
|
|
97
|
+
if not self._thread.is_alive():
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
done = threading.Event()
|
|
101
|
+
self._queue.put((kind, done))
|
|
102
|
+
done.wait(timeout=self.timeout + 1)
|
|
103
|
+
|
|
104
|
+
def _normalize(self, record: logging.LogRecord) -> Dict[str, Any]:
|
|
105
|
+
context = extra_context(record)
|
|
106
|
+
|
|
107
|
+
if record.exc_info:
|
|
108
|
+
context["exception"] = format_exception(record.exc_info, self.trace_limit)
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
"message": record.getMessage(),
|
|
112
|
+
"level": normalize_level(record.levelname),
|
|
113
|
+
"context": context,
|
|
114
|
+
"host": self._hostname,
|
|
115
|
+
"ts": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(
|
|
116
|
+
timespec="milliseconds"
|
|
117
|
+
),
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
def _run(self) -> None:
|
|
121
|
+
batch: List[Dict[str, Any]] = []
|
|
122
|
+
deadline: Optional[float] = None
|
|
123
|
+
|
|
124
|
+
while True:
|
|
125
|
+
wait = None if deadline is None else max(0.0, deadline - time.monotonic())
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
kind, payload = self._queue.get(timeout=wait)
|
|
129
|
+
except queue.Empty:
|
|
130
|
+
kind, payload = None, None
|
|
131
|
+
|
|
132
|
+
if kind == _LOG:
|
|
133
|
+
batch.append(payload)
|
|
134
|
+
if deadline is None:
|
|
135
|
+
deadline = time.monotonic() + self.batch_seconds
|
|
136
|
+
elif kind == _FLUSH:
|
|
137
|
+
self._send(batch)
|
|
138
|
+
batch, deadline = [], None
|
|
139
|
+
payload.set()
|
|
140
|
+
continue
|
|
141
|
+
elif kind == _CLOSE:
|
|
142
|
+
self._send(batch)
|
|
143
|
+
payload.set()
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
if batch and (
|
|
147
|
+
len(batch) >= self.batch_size
|
|
148
|
+
or (deadline is not None and time.monotonic() >= deadline)
|
|
149
|
+
):
|
|
150
|
+
self._send(batch)
|
|
151
|
+
batch, deadline = [], None
|
|
152
|
+
|
|
153
|
+
def _send(self, batch: List[Dict[str, Any]]) -> None:
|
|
154
|
+
if not batch:
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
send_batch(self.endpoint, self.token, batch, self.timeout)
|
|
159
|
+
except Exception as exc:
|
|
160
|
+
print(f"[tailcove] failed to send log batch: {exc}", file=sys.stderr)
|
tailcove/levels.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
#: Syslog-style levels, matching the set the ingest endpoint normalizes to
|
|
4
|
+
#: (see the log-ingestion skill). Standard library level names map onto
|
|
5
|
+
#: these directly; anything unrecognized (including a custom level name the
|
|
6
|
+
#: server has never heard of) falls back to "info", same as the server.
|
|
7
|
+
_LEVEL_MAP = {
|
|
8
|
+
"DEBUG": "debug",
|
|
9
|
+
"INFO": "info",
|
|
10
|
+
"NOTICE": "notice",
|
|
11
|
+
"WARNING": "warning",
|
|
12
|
+
"WARN": "warning",
|
|
13
|
+
"ERROR": "error",
|
|
14
|
+
"CRITICAL": "critical",
|
|
15
|
+
"FATAL": "critical",
|
|
16
|
+
"ALERT": "alert",
|
|
17
|
+
"EMERGENCY": "emergency",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def normalize_level(levelname: str) -> str:
|
|
22
|
+
return _LEVEL_MAP.get(levelname.upper(), "info")
|
tailcove/py.typed
ADDED
|
File without changes
|
tailcove/transport.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import urllib.error
|
|
5
|
+
import urllib.request
|
|
6
|
+
from typing import Any, Dict, List
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DeliveryError(Exception):
|
|
10
|
+
"""Raised when a batch fails to reach the ingest endpoint (network error or non-2xx response)."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def send_batch(
|
|
14
|
+
endpoint: str,
|
|
15
|
+
token: str,
|
|
16
|
+
batch: List[Dict[str, Any]],
|
|
17
|
+
timeout: float,
|
|
18
|
+
) -> None:
|
|
19
|
+
"""
|
|
20
|
+
Ships a batch as NDJSON — one JSON object per line, per the ingest
|
|
21
|
+
endpoint's payload sniffing (see the log-ingestion skill). Uses only
|
|
22
|
+
`urllib` from the standard library so the package has zero required
|
|
23
|
+
dependencies. Raises DeliveryError on a network failure or non-2xx
|
|
24
|
+
response; callers decide whether that's swallowed or surfaced.
|
|
25
|
+
"""
|
|
26
|
+
body = "\n".join(json.dumps(line, default=str) for line in batch).encode("utf-8")
|
|
27
|
+
|
|
28
|
+
request = urllib.request.Request(
|
|
29
|
+
endpoint,
|
|
30
|
+
data=body,
|
|
31
|
+
method="POST",
|
|
32
|
+
headers={
|
|
33
|
+
"Content-Type": "application/x-ndjson",
|
|
34
|
+
"Authorization": f"Bearer {token}",
|
|
35
|
+
},
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
40
|
+
status = response.status
|
|
41
|
+
except urllib.error.HTTPError as exc:
|
|
42
|
+
raise DeliveryError(
|
|
43
|
+
f"tailcove: ingest endpoint responded with status {exc.code}"
|
|
44
|
+
) from exc
|
|
45
|
+
except urllib.error.URLError as exc:
|
|
46
|
+
raise DeliveryError(
|
|
47
|
+
f"tailcove: request to ingest endpoint failed ({exc.reason})"
|
|
48
|
+
) from exc
|
|
49
|
+
|
|
50
|
+
if status >= 300:
|
|
51
|
+
raise DeliveryError(f"tailcove: ingest endpoint responded with status {status}")
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: tailcove
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for shipping application logs to Tailcove — a stdlib logging.Handler that batches and ships NDJSON.
|
|
5
|
+
Project-URL: Homepage, https://tailcove.dev
|
|
6
|
+
Project-URL: Documentation, https://tailcove.dev/docs/sending-logs/python
|
|
7
|
+
Project-URL: Source, https://github.com/tailcove/python
|
|
8
|
+
Project-URL: Issues, https://github.com/tailcove/python/issues
|
|
9
|
+
Author-email: Akcybex <developers@akcybex.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: logging,monolog,ndjson,tailcove
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: System :: Logging
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
<h1 align="center">tailcove</h1>
|
|
28
|
+
|
|
29
|
+
<p align="center">
|
|
30
|
+
Send Python logs to <a href="https://tailcove.dev">Tailcove</a> with the standard <code>logging</code> module.<br>
|
|
31
|
+
One handler, no dependencies, and it never slows down your code.
|
|
32
|
+
</p>
|
|
33
|
+
|
|
34
|
+
<p align="center">
|
|
35
|
+
<a href="https://pypi.org/project/tailcove/"><img alt="PyPI version" src="https://img.shields.io/pypi/v/tailcove?color=2DD4BF&labelColor=0A0A0A"></a>
|
|
36
|
+
<a href="https://pypi.org/project/tailcove/"><img alt="Python versions" src="https://img.shields.io/pypi/pyversions/tailcove?color=2DD4BF&labelColor=0A0A0A"></a>
|
|
37
|
+
<a href="https://pypi.org/project/tailcove/"><img alt="PyPI downloads" src="https://img.shields.io/pypi/dm/tailcove?color=2DD4BF&labelColor=0A0A0A"></a>
|
|
38
|
+
<a href="LICENSE"><img alt="MIT license" src="https://img.shields.io/badge/license-MIT-2DD4BF?labelColor=0A0A0A"></a>
|
|
39
|
+
</p>
|
|
40
|
+
|
|
41
|
+
<p align="center">
|
|
42
|
+
<a href="https://tailcove.dev/docs/sending-logs/python">Documentation</a> ·
|
|
43
|
+
<a href="https://github.com/tailcove/python/issues">Report a bug</a> ·
|
|
44
|
+
<a href="https://app.tailcove.dev/register">Get started free</a>
|
|
45
|
+
</p>
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Why use it
|
|
50
|
+
|
|
51
|
+
- **Plain `logging`.** `TailcoveHandler` is a normal `logging.Handler`. Keep the loggers you already have.
|
|
52
|
+
- **Never blocks.** Records are batched and sent from a background thread, so a request or a hot loop never waits on the network.
|
|
53
|
+
- **No dependencies.** Delivery uses `urllib` from the standard library.
|
|
54
|
+
- **Safe.** A failed delivery is caught and never raised into your code.
|
|
55
|
+
- **Structured exceptions.** Log an exception and it arrives as data (class, message, location, trace), so identical errors group together.
|
|
56
|
+
|
|
57
|
+
## Install
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install tailcove
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Requires Python 3.9 or newer.
|
|
64
|
+
|
|
65
|
+
## Quick start
|
|
66
|
+
|
|
67
|
+
**1. Get a token.** Create a source in your [Tailcove dashboard](https://app.tailcove.dev) and copy its ingest token. It is shown only once, so save it somewhere safe (an environment variable is ideal).
|
|
68
|
+
|
|
69
|
+
**2. Add the handler.**
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
import logging
|
|
73
|
+
import os
|
|
74
|
+
|
|
75
|
+
from tailcove import TailcoveHandler
|
|
76
|
+
|
|
77
|
+
logger = logging.getLogger("myapp")
|
|
78
|
+
logger.addHandler(TailcoveHandler(token=os.environ["TAILCOVE_TOKEN"]))
|
|
79
|
+
logger.setLevel(logging.INFO)
|
|
80
|
+
|
|
81
|
+
logger.info("user signed up", extra={"user_id": 42})
|
|
82
|
+
logger.error("payment failed", extra={"order_id": 99, "reason": "card_declined"})
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
**3. Open Tailcove.** Your records appear in search and live tail within a couple of seconds.
|
|
86
|
+
|
|
87
|
+
Buffered records are flushed when your program exits normally, because Python's own `logging.shutdown()` closes every handler.
|
|
88
|
+
|
|
89
|
+
## Options
|
|
90
|
+
|
|
91
|
+
| Argument | Default | What it does |
|
|
92
|
+
|---|---|---|
|
|
93
|
+
| `token` | — | Your source's ingest token. **Required.** |
|
|
94
|
+
| `endpoint` | `https://ingest.tailcove.dev/api/v1/logs` | The ingest URL. Only change this if you self-host. |
|
|
95
|
+
| `batch_size` | `50` | Send once this many records are buffered. |
|
|
96
|
+
| `batch_seconds` | `2.0` | Send once this many seconds have passed since the first buffered record. |
|
|
97
|
+
| `timeout` | `2.0` | Seconds a delivery attempt may take before giving up. |
|
|
98
|
+
| `trace_limit` | `10` | Most stack frames kept when a logged exception is rendered. |
|
|
99
|
+
| `level` | `logging.NOTSET` | Lowest level this handler processes, on top of the logger's own level. |
|
|
100
|
+
|
|
101
|
+
Records are sent once `batch_size` is reached or `batch_seconds` has passed, whichever comes first. They are never sent one HTTP request per log call. To send whatever is buffered right now, call `handler.flush()`. It blocks until the background thread confirms.
|
|
102
|
+
|
|
103
|
+
## Structured context
|
|
104
|
+
|
|
105
|
+
Anything you pass in `extra={...}` is shipped as the line's context, with the same keys:
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
logger.info("order placed", extra={"order_id": 99, "total_cents": 4200})
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Exceptions
|
|
112
|
+
|
|
113
|
+
Log with `exc_info=True`, or use `logger.exception(...)` inside an `except` block, and the exception is sent as structured data and not as a flattened string:
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
try:
|
|
117
|
+
charge_card(order)
|
|
118
|
+
except CardDeclined:
|
|
119
|
+
logger.error("payment failed", exc_info=True)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
```json
|
|
123
|
+
"exception": {
|
|
124
|
+
"class": "billing.errors.CardDeclined",
|
|
125
|
+
"message": "insufficient funds",
|
|
126
|
+
"location": "/app/billing/charge.py:42",
|
|
127
|
+
"trace": [
|
|
128
|
+
"/app/billing/charge.py:42 in charge_card",
|
|
129
|
+
"/app/orders/service.py:17 in place_order"
|
|
130
|
+
]
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
`location` is where the exception was raised, and `trace` is limited to `trace_limit` frames. It's the same shape the Laravel, Node and browser libraries use, so the same error groups into one issue whichever app reported it.
|
|
135
|
+
|
|
136
|
+
## Frameworks
|
|
137
|
+
|
|
138
|
+
### Django
|
|
139
|
+
|
|
140
|
+
Add the handler to `LOGGING` in `settings.py`:
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
LOGGING = {
|
|
144
|
+
"version": 1,
|
|
145
|
+
"disable_existing_loggers": False,
|
|
146
|
+
"handlers": {
|
|
147
|
+
"tailcove": {
|
|
148
|
+
"class": "tailcove.TailcoveHandler",
|
|
149
|
+
"token": env("TAILCOVE_TOKEN"),
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
"root": {
|
|
153
|
+
"handlers": ["console", "tailcove"],
|
|
154
|
+
"level": "INFO",
|
|
155
|
+
},
|
|
156
|
+
"loggers": {
|
|
157
|
+
"django.request": {
|
|
158
|
+
"handlers": ["tailcove"],
|
|
159
|
+
"level": "ERROR",
|
|
160
|
+
"propagate": True,
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### Flask
|
|
167
|
+
|
|
168
|
+
Attach the handler to the app logger, or to the root logger if you want every `logging.getLogger(__name__)` in the app to ship too:
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
from flask import Flask
|
|
172
|
+
from tailcove import TailcoveHandler
|
|
173
|
+
|
|
174
|
+
app = Flask(__name__)
|
|
175
|
+
app.logger.addHandler(TailcoveHandler(token=app.config["TAILCOVE_TOKEN"]))
|
|
176
|
+
app.logger.setLevel("INFO")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@app.errorhandler(Exception)
|
|
180
|
+
def handle_exception(exc):
|
|
181
|
+
app.logger.error("unhandled exception", exc_info=exc)
|
|
182
|
+
raise
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## When delivery fails
|
|
186
|
+
|
|
187
|
+
Network errors and non-2xx responses never raise into your code. `emit()` follows the standard `Handler` convention and routes its own failures through `handleError()`. Errors on the background thread are caught and printed to stderr. Logging is best-effort by design.
|
|
188
|
+
|
|
189
|
+
## Help and feedback
|
|
190
|
+
|
|
191
|
+
- **Docs:** [tailcove.dev/docs/sending-logs/python](https://tailcove.dev/docs/sending-logs/python)
|
|
192
|
+
- **Bugs and feature requests:** [open an issue](https://github.com/tailcove/python/issues/new/choose)
|
|
193
|
+
- **Security problems:** please email **security@tailcove.dev** instead of opening a public issue.
|
|
194
|
+
|
|
195
|
+
## Other Tailcove libraries
|
|
196
|
+
|
|
197
|
+
[Laravel](https://github.com/tailcove/laravel) · [Node.js](https://github.com/tailcove/node) · [Browser](https://github.com/tailcove/browser)
|
|
198
|
+
|
|
199
|
+
## License
|
|
200
|
+
|
|
201
|
+
[MIT](LICENSE) © Akcybex
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
tailcove/__init__.py,sha256=M6jEyWQipvkW18ELB11Q4S_3DMJadjwqy5nD92aF_-w,144
|
|
2
|
+
tailcove/context.py,sha256=ePdDScnCQeY7ngXjDJM-rcC0W-vvEUSyVSropf73DcA,964
|
|
3
|
+
tailcove/exceptions.py,sha256=kjpZL4YUyoXpuD0WAH9LDFlAM6Q6kthVMXa61iyP8cc,1533
|
|
4
|
+
tailcove/handler.py,sha256=669QGPvOvEl2mbqHbdNkLXCBH0vDUwpIGjbe5uFppqs,5503
|
|
5
|
+
tailcove/levels.py,sha256=-y7b2vvz2klWQ7VnAkqWK1fcIhiQE6LMp8Sm5e0ZcVg,689
|
|
6
|
+
tailcove/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
tailcove/transport.py,sha256=zySSxWSgNsnL4mVSCBNzPMIbcJlfZzZb45xWlY3kKdU,1631
|
|
8
|
+
tailcove-0.1.0.dist-info/METADATA,sha256=1_BWAco6_iX7lkpcPIN2LqquIGESuWThMjUWW3SSxEc,7431
|
|
9
|
+
tailcove-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
10
|
+
tailcove-0.1.0.dist-info/licenses/LICENSE,sha256=mHEELvGtWbKsucOMiKAMnUeQMOZY3R4-IGGc1IEGUWE,1064
|
|
11
|
+
tailcove-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Akcybex
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|