tailcove 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.
- tailcove-0.1.0/.github/ISSUE_TEMPLATE/bug_report.yml +59 -0
- tailcove-0.1.0/.github/ISSUE_TEMPLATE/config.yml +8 -0
- tailcove-0.1.0/.github/ISSUE_TEMPLATE/feature_request.yml +30 -0
- tailcove-0.1.0/.github/workflows/publish.yml +58 -0
- tailcove-0.1.0/.gitignore +6 -0
- tailcove-0.1.0/LICENSE +21 -0
- tailcove-0.1.0/PKG-INFO +201 -0
- tailcove-0.1.0/README.md +175 -0
- tailcove-0.1.0/pyproject.toml +41 -0
- tailcove-0.1.0/src/tailcove/__init__.py +5 -0
- tailcove-0.1.0/src/tailcove/context.py +25 -0
- tailcove-0.1.0/src/tailcove/exceptions.py +40 -0
- tailcove-0.1.0/src/tailcove/handler.py +160 -0
- tailcove-0.1.0/src/tailcove/levels.py +22 -0
- tailcove-0.1.0/src/tailcove/py.typed +0 -0
- tailcove-0.1.0/src/tailcove/transport.py +51 -0
- tailcove-0.1.0/tests/conftest.py +97 -0
- tailcove-0.1.0/tests/helpers.py +24 -0
- tailcove-0.1.0/tests/test_atexit.py +38 -0
- tailcove-0.1.0/tests/test_batching.py +133 -0
- tailcove-0.1.0/tests/test_context.py +29 -0
- tailcove-0.1.0/tests/test_exception_shape.py +84 -0
- tailcove-0.1.0/tests/test_never_raise.py +53 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
name: Bug report
|
|
2
|
+
description: Something in tailcove isn't working as expected.
|
|
3
|
+
labels: [bug]
|
|
4
|
+
body:
|
|
5
|
+
- type: markdown
|
|
6
|
+
attributes:
|
|
7
|
+
value: |
|
|
8
|
+
Thanks for taking the time to report this. The more detail you give, the faster we can fix it.
|
|
9
|
+
|
|
10
|
+
**Never paste your ingest token or browser key.** Replace it with `***`.
|
|
11
|
+
- type: textarea
|
|
12
|
+
id: what-happened
|
|
13
|
+
attributes:
|
|
14
|
+
label: What happened?
|
|
15
|
+
description: Tell us what you saw, and what you expected to see instead.
|
|
16
|
+
validations:
|
|
17
|
+
required: true
|
|
18
|
+
- type: textarea
|
|
19
|
+
id: reproduce
|
|
20
|
+
attributes:
|
|
21
|
+
label: How can we reproduce it?
|
|
22
|
+
description: The smallest code or steps that show the problem.
|
|
23
|
+
render: python
|
|
24
|
+
validations:
|
|
25
|
+
required: true
|
|
26
|
+
- type: input
|
|
27
|
+
id: version
|
|
28
|
+
attributes:
|
|
29
|
+
label: tailcove version
|
|
30
|
+
placeholder: "0.1.1"
|
|
31
|
+
validations:
|
|
32
|
+
required: true
|
|
33
|
+
- type: input
|
|
34
|
+
id: runtime
|
|
35
|
+
attributes:
|
|
36
|
+
label: Python version
|
|
37
|
+
placeholder: "3.12.4"
|
|
38
|
+
validations:
|
|
39
|
+
required: true
|
|
40
|
+
- type: input
|
|
41
|
+
id: framework
|
|
42
|
+
attributes:
|
|
43
|
+
label: Framework (optional)
|
|
44
|
+
placeholder: "Django 5.1, Flask 3.0"
|
|
45
|
+
- type: textarea
|
|
46
|
+
id: logs
|
|
47
|
+
attributes:
|
|
48
|
+
label: Errors or output
|
|
49
|
+
description: Any error message, stack trace or console output. This is formatted as code automatically.
|
|
50
|
+
render: shell
|
|
51
|
+
- type: checkboxes
|
|
52
|
+
id: checks
|
|
53
|
+
attributes:
|
|
54
|
+
label: Before submitting
|
|
55
|
+
options:
|
|
56
|
+
- label: I searched existing issues and didn't find a duplicate.
|
|
57
|
+
required: true
|
|
58
|
+
- label: I removed any tokens, keys and private data from this report.
|
|
59
|
+
required: true
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
blank_issues_enabled: false
|
|
2
|
+
contact_links:
|
|
3
|
+
- name: Documentation
|
|
4
|
+
url: https://tailcove.dev/docs/sending-logs/python
|
|
5
|
+
about: Setup guides and answers to common questions.
|
|
6
|
+
- name: Security vulnerability
|
|
7
|
+
url: mailto:security@tailcove.dev
|
|
8
|
+
about: Please email us privately instead of opening a public issue.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
name: Feature request
|
|
2
|
+
description: Suggest an improvement to tailcove.
|
|
3
|
+
labels: [enhancement]
|
|
4
|
+
body:
|
|
5
|
+
- type: textarea
|
|
6
|
+
id: problem
|
|
7
|
+
attributes:
|
|
8
|
+
label: What problem are you trying to solve?
|
|
9
|
+
description: Describe what you're trying to do and where tailcove gets in the way.
|
|
10
|
+
validations:
|
|
11
|
+
required: true
|
|
12
|
+
- type: textarea
|
|
13
|
+
id: solution
|
|
14
|
+
attributes:
|
|
15
|
+
label: What would you like to see?
|
|
16
|
+
description: How you imagine it working. Example code is welcome.
|
|
17
|
+
validations:
|
|
18
|
+
required: true
|
|
19
|
+
- type: textarea
|
|
20
|
+
id: alternatives
|
|
21
|
+
attributes:
|
|
22
|
+
label: Alternatives you've considered
|
|
23
|
+
description: Other approaches or workarounds you've tried.
|
|
24
|
+
- type: checkboxes
|
|
25
|
+
id: checks
|
|
26
|
+
attributes:
|
|
27
|
+
label: Before submitting
|
|
28
|
+
options:
|
|
29
|
+
- label: I searched existing issues and didn't find a duplicate.
|
|
30
|
+
required: true
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
name: publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- 'v*'
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
build:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
|
|
17
|
+
- uses: actions/setup-python@v5
|
|
18
|
+
with:
|
|
19
|
+
python-version: '3.12'
|
|
20
|
+
|
|
21
|
+
- name: Check tag matches package version
|
|
22
|
+
run: |
|
|
23
|
+
version="$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")"
|
|
24
|
+
if [ "v$version" != "$GITHUB_REF_NAME" ]; then
|
|
25
|
+
echo "Tag $GITHUB_REF_NAME does not match pyproject.toml version $version" >&2
|
|
26
|
+
exit 1
|
|
27
|
+
fi
|
|
28
|
+
|
|
29
|
+
- name: Test
|
|
30
|
+
run: |
|
|
31
|
+
python -m pip install --upgrade pip
|
|
32
|
+
python -m pip install -e '.[dev]'
|
|
33
|
+
python -m pytest
|
|
34
|
+
|
|
35
|
+
- name: Build
|
|
36
|
+
run: |
|
|
37
|
+
python -m pip install build twine
|
|
38
|
+
python -m build
|
|
39
|
+
python -m twine check dist/*
|
|
40
|
+
|
|
41
|
+
- uses: actions/upload-artifact@v4
|
|
42
|
+
with:
|
|
43
|
+
name: dist
|
|
44
|
+
path: dist/
|
|
45
|
+
|
|
46
|
+
publish:
|
|
47
|
+
needs: build
|
|
48
|
+
runs-on: ubuntu-latest
|
|
49
|
+
environment: pypi
|
|
50
|
+
permissions:
|
|
51
|
+
id-token: write
|
|
52
|
+
steps:
|
|
53
|
+
- uses: actions/download-artifact@v4
|
|
54
|
+
with:
|
|
55
|
+
name: dist
|
|
56
|
+
path: dist/
|
|
57
|
+
|
|
58
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
tailcove-0.1.0/LICENSE
ADDED
|
@@ -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.
|
tailcove-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
tailcove-0.1.0/README.md
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
<h1 align="center">tailcove</h1>
|
|
2
|
+
|
|
3
|
+
<p align="center">
|
|
4
|
+
Send Python logs to <a href="https://tailcove.dev">Tailcove</a> with the standard <code>logging</code> module.<br>
|
|
5
|
+
One handler, no dependencies, and it never slows down your code.
|
|
6
|
+
</p>
|
|
7
|
+
|
|
8
|
+
<p align="center">
|
|
9
|
+
<a href="https://pypi.org/project/tailcove/"><img alt="PyPI version" src="https://img.shields.io/pypi/v/tailcove?color=2DD4BF&labelColor=0A0A0A"></a>
|
|
10
|
+
<a href="https://pypi.org/project/tailcove/"><img alt="Python versions" src="https://img.shields.io/pypi/pyversions/tailcove?color=2DD4BF&labelColor=0A0A0A"></a>
|
|
11
|
+
<a href="https://pypi.org/project/tailcove/"><img alt="PyPI downloads" src="https://img.shields.io/pypi/dm/tailcove?color=2DD4BF&labelColor=0A0A0A"></a>
|
|
12
|
+
<a href="LICENSE"><img alt="MIT license" src="https://img.shields.io/badge/license-MIT-2DD4BF?labelColor=0A0A0A"></a>
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
<p align="center">
|
|
16
|
+
<a href="https://tailcove.dev/docs/sending-logs/python">Documentation</a> ·
|
|
17
|
+
<a href="https://github.com/tailcove/python/issues">Report a bug</a> ·
|
|
18
|
+
<a href="https://app.tailcove.dev/register">Get started free</a>
|
|
19
|
+
</p>
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Why use it
|
|
24
|
+
|
|
25
|
+
- **Plain `logging`.** `TailcoveHandler` is a normal `logging.Handler`. Keep the loggers you already have.
|
|
26
|
+
- **Never blocks.** Records are batched and sent from a background thread, so a request or a hot loop never waits on the network.
|
|
27
|
+
- **No dependencies.** Delivery uses `urllib` from the standard library.
|
|
28
|
+
- **Safe.** A failed delivery is caught and never raised into your code.
|
|
29
|
+
- **Structured exceptions.** Log an exception and it arrives as data (class, message, location, trace), so identical errors group together.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install tailcove
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Requires Python 3.9 or newer.
|
|
38
|
+
|
|
39
|
+
## Quick start
|
|
40
|
+
|
|
41
|
+
**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).
|
|
42
|
+
|
|
43
|
+
**2. Add the handler.**
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
import logging
|
|
47
|
+
import os
|
|
48
|
+
|
|
49
|
+
from tailcove import TailcoveHandler
|
|
50
|
+
|
|
51
|
+
logger = logging.getLogger("myapp")
|
|
52
|
+
logger.addHandler(TailcoveHandler(token=os.environ["TAILCOVE_TOKEN"]))
|
|
53
|
+
logger.setLevel(logging.INFO)
|
|
54
|
+
|
|
55
|
+
logger.info("user signed up", extra={"user_id": 42})
|
|
56
|
+
logger.error("payment failed", extra={"order_id": 99, "reason": "card_declined"})
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
**3. Open Tailcove.** Your records appear in search and live tail within a couple of seconds.
|
|
60
|
+
|
|
61
|
+
Buffered records are flushed when your program exits normally, because Python's own `logging.shutdown()` closes every handler.
|
|
62
|
+
|
|
63
|
+
## Options
|
|
64
|
+
|
|
65
|
+
| Argument | Default | What it does |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| `token` | — | Your source's ingest token. **Required.** |
|
|
68
|
+
| `endpoint` | `https://ingest.tailcove.dev/api/v1/logs` | The ingest URL. Only change this if you self-host. |
|
|
69
|
+
| `batch_size` | `50` | Send once this many records are buffered. |
|
|
70
|
+
| `batch_seconds` | `2.0` | Send once this many seconds have passed since the first buffered record. |
|
|
71
|
+
| `timeout` | `2.0` | Seconds a delivery attempt may take before giving up. |
|
|
72
|
+
| `trace_limit` | `10` | Most stack frames kept when a logged exception is rendered. |
|
|
73
|
+
| `level` | `logging.NOTSET` | Lowest level this handler processes, on top of the logger's own level. |
|
|
74
|
+
|
|
75
|
+
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.
|
|
76
|
+
|
|
77
|
+
## Structured context
|
|
78
|
+
|
|
79
|
+
Anything you pass in `extra={...}` is shipped as the line's context, with the same keys:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
logger.info("order placed", extra={"order_id": 99, "total_cents": 4200})
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Exceptions
|
|
86
|
+
|
|
87
|
+
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:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
try:
|
|
91
|
+
charge_card(order)
|
|
92
|
+
except CardDeclined:
|
|
93
|
+
logger.error("payment failed", exc_info=True)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
```json
|
|
97
|
+
"exception": {
|
|
98
|
+
"class": "billing.errors.CardDeclined",
|
|
99
|
+
"message": "insufficient funds",
|
|
100
|
+
"location": "/app/billing/charge.py:42",
|
|
101
|
+
"trace": [
|
|
102
|
+
"/app/billing/charge.py:42 in charge_card",
|
|
103
|
+
"/app/orders/service.py:17 in place_order"
|
|
104
|
+
]
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`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.
|
|
109
|
+
|
|
110
|
+
## Frameworks
|
|
111
|
+
|
|
112
|
+
### Django
|
|
113
|
+
|
|
114
|
+
Add the handler to `LOGGING` in `settings.py`:
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
LOGGING = {
|
|
118
|
+
"version": 1,
|
|
119
|
+
"disable_existing_loggers": False,
|
|
120
|
+
"handlers": {
|
|
121
|
+
"tailcove": {
|
|
122
|
+
"class": "tailcove.TailcoveHandler",
|
|
123
|
+
"token": env("TAILCOVE_TOKEN"),
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
"root": {
|
|
127
|
+
"handlers": ["console", "tailcove"],
|
|
128
|
+
"level": "INFO",
|
|
129
|
+
},
|
|
130
|
+
"loggers": {
|
|
131
|
+
"django.request": {
|
|
132
|
+
"handlers": ["tailcove"],
|
|
133
|
+
"level": "ERROR",
|
|
134
|
+
"propagate": True,
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Flask
|
|
141
|
+
|
|
142
|
+
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:
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
from flask import Flask
|
|
146
|
+
from tailcove import TailcoveHandler
|
|
147
|
+
|
|
148
|
+
app = Flask(__name__)
|
|
149
|
+
app.logger.addHandler(TailcoveHandler(token=app.config["TAILCOVE_TOKEN"]))
|
|
150
|
+
app.logger.setLevel("INFO")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@app.errorhandler(Exception)
|
|
154
|
+
def handle_exception(exc):
|
|
155
|
+
app.logger.error("unhandled exception", exc_info=exc)
|
|
156
|
+
raise
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## When delivery fails
|
|
160
|
+
|
|
161
|
+
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.
|
|
162
|
+
|
|
163
|
+
## Help and feedback
|
|
164
|
+
|
|
165
|
+
- **Docs:** [tailcove.dev/docs/sending-logs/python](https://tailcove.dev/docs/sending-logs/python)
|
|
166
|
+
- **Bugs and feature requests:** [open an issue](https://github.com/tailcove/python/issues/new/choose)
|
|
167
|
+
- **Security problems:** please email **security@tailcove.dev** instead of opening a public issue.
|
|
168
|
+
|
|
169
|
+
## Other Tailcove libraries
|
|
170
|
+
|
|
171
|
+
[Laravel](https://github.com/tailcove/laravel) · [Node.js](https://github.com/tailcove/node) · [Browser](https://github.com/tailcove/browser)
|
|
172
|
+
|
|
173
|
+
## License
|
|
174
|
+
|
|
175
|
+
[MIT](LICENSE) © Akcybex
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tailcove"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python client for shipping application logs to Tailcove — a stdlib logging.Handler that batches and ships NDJSON."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
keywords = ["logging", "tailcove", "monolog", "ndjson"]
|
|
14
|
+
authors = [{ name = "Akcybex", email = "developers@akcybex.com" }]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.9",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Topic :: System :: Logging",
|
|
24
|
+
"Typing :: Typed",
|
|
25
|
+
]
|
|
26
|
+
dependencies = []
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
dev = ["pytest>=7"]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://tailcove.dev"
|
|
33
|
+
Documentation = "https://tailcove.dev/docs/sending-logs/python"
|
|
34
|
+
Source = "https://github.com/tailcove/python"
|
|
35
|
+
Issues = "https://github.com/tailcove/python/issues"
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.wheel]
|
|
38
|
+
packages = ["src/tailcove"]
|
|
39
|
+
|
|
40
|
+
[tool.pytest.ini_options]
|
|
41
|
+
testpaths = ["tests"]
|
|
@@ -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}
|
|
@@ -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
|
+
}
|