nanotelemetry 0.2.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.
- nanotelemetry-0.2.0/.gitignore +21 -0
- nanotelemetry-0.2.0/LICENSE +21 -0
- nanotelemetry-0.2.0/PKG-INFO +148 -0
- nanotelemetry-0.2.0/README.md +123 -0
- nanotelemetry-0.2.0/pyproject.toml +37 -0
- nanotelemetry-0.2.0/src/nanotelemetry/__init__.py +36 -0
- nanotelemetry-0.2.0/src/nanotelemetry/client.py +154 -0
- nanotelemetry-0.2.0/src/nanotelemetry/events.py +48 -0
- nanotelemetry-0.2.0/src/nanotelemetry/exporters.py +50 -0
- nanotelemetry-0.2.0/src/nanotelemetry/py.typed +0 -0
- nanotelemetry-0.2.0/src/nanotelemetry/sysinfo.py +51 -0
- nanotelemetry-0.2.0/tests/test_sysinfo.py +41 -0
- nanotelemetry-0.2.0/tests/test_telemetry.py +105 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 nanotelemetry contributors
|
|
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.
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nanotelemetry
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: A tiny, dependency-free telemetry library: events, counters, gauges, and timing spans.
|
|
5
|
+
Author-email: dark.soul.test.1@gmail.com
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: instrumentation,metrics,monitoring,observability,telemetry
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: System :: Monitoring
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# nanotelemetry
|
|
27
|
+
|
|
28
|
+
A tiny, dependency-free telemetry library for Python. Record events, counters,
|
|
29
|
+
gauges, and timing spans, then flush them to one or more pluggable exporters.
|
|
30
|
+
|
|
31
|
+
- **Zero dependencies** — pure standard library.
|
|
32
|
+
- **Thread-safe** — share one client across threads.
|
|
33
|
+
- **Pluggable exporters** — console, in-memory, or your own.
|
|
34
|
+
- **Typed** — ships with `py.typed`.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install nanotelemetry
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Quickstart
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from nanotelemetry import Telemetry, ConsoleExporter
|
|
46
|
+
|
|
47
|
+
tel = Telemetry(
|
|
48
|
+
service="my-app",
|
|
49
|
+
exporters=[ConsoleExporter()],
|
|
50
|
+
tags={"env": "prod"},
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Discrete events
|
|
54
|
+
tel.event("user.signup", plan="pro")
|
|
55
|
+
|
|
56
|
+
# Counters and gauges
|
|
57
|
+
tel.count("requests", 1, route="/home")
|
|
58
|
+
tel.gauge("queue.depth", 42)
|
|
59
|
+
|
|
60
|
+
# Time a block of code
|
|
61
|
+
with tel.span("db.query", table="users") as attrs:
|
|
62
|
+
rows = run_query()
|
|
63
|
+
attrs["rows"] = len(rows)
|
|
64
|
+
|
|
65
|
+
# ...or a whole function
|
|
66
|
+
@tel.timed("compute")
|
|
67
|
+
def compute():
|
|
68
|
+
...
|
|
69
|
+
|
|
70
|
+
tel.flush() # send buffered events to exporters
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Use it as a context manager to flush and close automatically:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
with Telemetry(service="my-app", exporters=[ConsoleExporter()]) as tel:
|
|
77
|
+
tel.event("startup")
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Event kinds
|
|
81
|
+
|
|
82
|
+
| Method | Kind | `value` means |
|
|
83
|
+
| ------------------- | ------- | ------------------------ |
|
|
84
|
+
| `event(name, **kw)` | `event` | (none) |
|
|
85
|
+
| `count(name, n)` | `count` | the increment |
|
|
86
|
+
| `gauge(name, x)` | `gauge` | the reading |
|
|
87
|
+
| `span(name)` | `span` | duration in seconds |
|
|
88
|
+
|
|
89
|
+
Every event carries the client's `service` name plus any `tags` you configured,
|
|
90
|
+
merged with the per-call attributes.
|
|
91
|
+
|
|
92
|
+
## System info
|
|
93
|
+
|
|
94
|
+
Pass `include_system_info=True` to automatically tag every event with details
|
|
95
|
+
about the host it's running on — hostname, OS, architecture, Python version,
|
|
96
|
+
and PID:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
tel = Telemetry(service="my-app", include_system_info=True)
|
|
100
|
+
tel.event("startup")
|
|
101
|
+
# attributes now include: host, os, os_release, arch, python, python_impl, pid
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
It's collected once at construction and off by default. Any keys you set
|
|
105
|
+
explicitly in `tags` take precedence. You can also call the collector directly:
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
from nanotelemetry import sysinfo
|
|
109
|
+
print(sysinfo())
|
|
110
|
+
# {'host': 'laptop', 'os': 'Darwin', 'arch': 'arm64', 'python': '3.12.1', ...}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Batching
|
|
114
|
+
|
|
115
|
+
The client buffers events and auto-flushes once `batch_size` (default 100) is
|
|
116
|
+
reached. Set `batch_size=0` to disable auto-flush and call `flush()` yourself.
|
|
117
|
+
|
|
118
|
+
## Custom exporters
|
|
119
|
+
|
|
120
|
+
Subclass `Exporter` to send events anywhere:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
import json
|
|
124
|
+
import urllib.request
|
|
125
|
+
from nanotelemetry import Exporter
|
|
126
|
+
|
|
127
|
+
class HTTPExporter(Exporter):
|
|
128
|
+
def __init__(self, url):
|
|
129
|
+
self.url = url
|
|
130
|
+
|
|
131
|
+
def export(self, events):
|
|
132
|
+
payload = json.dumps([e.to_dict() for e in events]).encode()
|
|
133
|
+
req = urllib.request.Request(
|
|
134
|
+
self.url, data=payload, headers={"Content-Type": "application/json"}
|
|
135
|
+
)
|
|
136
|
+
urllib.request.urlopen(req)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Development
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
pip install -e ".[dev]"
|
|
143
|
+
pytest
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## License
|
|
147
|
+
|
|
148
|
+
MIT
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# nanotelemetry
|
|
2
|
+
|
|
3
|
+
A tiny, dependency-free telemetry library for Python. Record events, counters,
|
|
4
|
+
gauges, and timing spans, then flush them to one or more pluggable exporters.
|
|
5
|
+
|
|
6
|
+
- **Zero dependencies** — pure standard library.
|
|
7
|
+
- **Thread-safe** — share one client across threads.
|
|
8
|
+
- **Pluggable exporters** — console, in-memory, or your own.
|
|
9
|
+
- **Typed** — ships with `py.typed`.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install nanotelemetry
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quickstart
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from nanotelemetry import Telemetry, ConsoleExporter
|
|
21
|
+
|
|
22
|
+
tel = Telemetry(
|
|
23
|
+
service="my-app",
|
|
24
|
+
exporters=[ConsoleExporter()],
|
|
25
|
+
tags={"env": "prod"},
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# Discrete events
|
|
29
|
+
tel.event("user.signup", plan="pro")
|
|
30
|
+
|
|
31
|
+
# Counters and gauges
|
|
32
|
+
tel.count("requests", 1, route="/home")
|
|
33
|
+
tel.gauge("queue.depth", 42)
|
|
34
|
+
|
|
35
|
+
# Time a block of code
|
|
36
|
+
with tel.span("db.query", table="users") as attrs:
|
|
37
|
+
rows = run_query()
|
|
38
|
+
attrs["rows"] = len(rows)
|
|
39
|
+
|
|
40
|
+
# ...or a whole function
|
|
41
|
+
@tel.timed("compute")
|
|
42
|
+
def compute():
|
|
43
|
+
...
|
|
44
|
+
|
|
45
|
+
tel.flush() # send buffered events to exporters
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Use it as a context manager to flush and close automatically:
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
with Telemetry(service="my-app", exporters=[ConsoleExporter()]) as tel:
|
|
52
|
+
tel.event("startup")
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Event kinds
|
|
56
|
+
|
|
57
|
+
| Method | Kind | `value` means |
|
|
58
|
+
| ------------------- | ------- | ------------------------ |
|
|
59
|
+
| `event(name, **kw)` | `event` | (none) |
|
|
60
|
+
| `count(name, n)` | `count` | the increment |
|
|
61
|
+
| `gauge(name, x)` | `gauge` | the reading |
|
|
62
|
+
| `span(name)` | `span` | duration in seconds |
|
|
63
|
+
|
|
64
|
+
Every event carries the client's `service` name plus any `tags` you configured,
|
|
65
|
+
merged with the per-call attributes.
|
|
66
|
+
|
|
67
|
+
## System info
|
|
68
|
+
|
|
69
|
+
Pass `include_system_info=True` to automatically tag every event with details
|
|
70
|
+
about the host it's running on — hostname, OS, architecture, Python version,
|
|
71
|
+
and PID:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
tel = Telemetry(service="my-app", include_system_info=True)
|
|
75
|
+
tel.event("startup")
|
|
76
|
+
# attributes now include: host, os, os_release, arch, python, python_impl, pid
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
It's collected once at construction and off by default. Any keys you set
|
|
80
|
+
explicitly in `tags` take precedence. You can also call the collector directly:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from nanotelemetry import sysinfo
|
|
84
|
+
print(sysinfo())
|
|
85
|
+
# {'host': 'laptop', 'os': 'Darwin', 'arch': 'arm64', 'python': '3.12.1', ...}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Batching
|
|
89
|
+
|
|
90
|
+
The client buffers events and auto-flushes once `batch_size` (default 100) is
|
|
91
|
+
reached. Set `batch_size=0` to disable auto-flush and call `flush()` yourself.
|
|
92
|
+
|
|
93
|
+
## Custom exporters
|
|
94
|
+
|
|
95
|
+
Subclass `Exporter` to send events anywhere:
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
import json
|
|
99
|
+
import urllib.request
|
|
100
|
+
from nanotelemetry import Exporter
|
|
101
|
+
|
|
102
|
+
class HTTPExporter(Exporter):
|
|
103
|
+
def __init__(self, url):
|
|
104
|
+
self.url = url
|
|
105
|
+
|
|
106
|
+
def export(self, events):
|
|
107
|
+
payload = json.dumps([e.to_dict() for e in events]).encode()
|
|
108
|
+
req = urllib.request.Request(
|
|
109
|
+
self.url, data=payload, headers={"Content-Type": "application/json"}
|
|
110
|
+
)
|
|
111
|
+
urllib.request.urlopen(req)
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Development
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
pip install -e ".[dev]"
|
|
118
|
+
pytest
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## License
|
|
122
|
+
|
|
123
|
+
MIT
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "nanotelemetry"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "A tiny, dependency-free telemetry library: events, counters, gauges, and timing spans."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ email = "dark.soul.test.1@gmail.com" }]
|
|
13
|
+
keywords = ["telemetry", "metrics", "observability", "instrumentation", "monitoring"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.9",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Topic :: System :: Monitoring",
|
|
26
|
+
"Typing :: Typed",
|
|
27
|
+
]
|
|
28
|
+
dependencies = []
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
dev = ["pytest>=7.0"]
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.targets.wheel]
|
|
34
|
+
packages = ["src/nanotelemetry"]
|
|
35
|
+
|
|
36
|
+
[tool.pytest.ini_options]
|
|
37
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""nanotelemetry — a tiny, dependency-free telemetry library.
|
|
2
|
+
|
|
3
|
+
Record events, counters, gauges, and timing spans, then ship them to one or
|
|
4
|
+
more exporters (console, in-memory, or your own).
|
|
5
|
+
|
|
6
|
+
Basic usage::
|
|
7
|
+
|
|
8
|
+
from nanotelemetry import Telemetry, ConsoleExporter
|
|
9
|
+
|
|
10
|
+
tel = Telemetry(service="my-app", exporters=[ConsoleExporter()])
|
|
11
|
+
tel.event("user.signup", plan="pro")
|
|
12
|
+
tel.count("requests", 1, route="/home")
|
|
13
|
+
|
|
14
|
+
with tel.span("db.query", table="users"):
|
|
15
|
+
run_query()
|
|
16
|
+
|
|
17
|
+
tel.flush()
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from .client import Telemetry
|
|
21
|
+
from .events import Event, EventKind
|
|
22
|
+
from .exporters import ConsoleExporter, Exporter, MemoryExporter
|
|
23
|
+
from .sysinfo import sysinfo
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"Telemetry",
|
|
27
|
+
"Event",
|
|
28
|
+
"EventKind",
|
|
29
|
+
"Exporter",
|
|
30
|
+
"ConsoleExporter",
|
|
31
|
+
"MemoryExporter",
|
|
32
|
+
"sysinfo",
|
|
33
|
+
"__version__",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
__version__ = "0.2.0"
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""The Telemetry client — the main entry point of the library."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from typing import Any, Callable, Dict, Iterator, List, Optional, Sequence, TypeVar
|
|
10
|
+
|
|
11
|
+
from .events import Event, EventKind
|
|
12
|
+
from .exporters import Exporter
|
|
13
|
+
from .sysinfo import sysinfo
|
|
14
|
+
|
|
15
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Telemetry:
|
|
19
|
+
"""Collects telemetry events and flushes them to exporters.
|
|
20
|
+
|
|
21
|
+
The client buffers events in memory and sends them to every configured
|
|
22
|
+
exporter when the buffer reaches ``batch_size`` or when :meth:`flush` is
|
|
23
|
+
called. It is safe to share a single instance across threads.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
service: Name of the service emitting telemetry. Added to every event's
|
|
27
|
+
attributes under ``"service"``.
|
|
28
|
+
exporters: Where to send events. If omitted, events are buffered until
|
|
29
|
+
an exporter is added or :meth:`flush` is called (and then dropped).
|
|
30
|
+
tags: Attributes merged into every event, e.g. ``{"env": "prod"}``.
|
|
31
|
+
batch_size: Auto-flush once this many events are buffered. Set to 0 to
|
|
32
|
+
disable auto-flushing (flush manually).
|
|
33
|
+
include_system_info: If ``True``, collect host/OS/arch/Python/PID once
|
|
34
|
+
at construction (see :func:`nanotelemetry.sysinfo`) and merge it
|
|
35
|
+
into ``tags``. Explicit ``tags`` win on any key collision.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
service: str,
|
|
41
|
+
exporters: Optional[Sequence[Exporter]] = None,
|
|
42
|
+
tags: Optional[Dict[str, Any]] = None,
|
|
43
|
+
batch_size: int = 100,
|
|
44
|
+
include_system_info: bool = False,
|
|
45
|
+
) -> None:
|
|
46
|
+
self.service = service
|
|
47
|
+
self.exporters: List[Exporter] = list(exporters or [])
|
|
48
|
+
base_tags = sysinfo() if include_system_info else {}
|
|
49
|
+
base_tags.update(tags or {})
|
|
50
|
+
self.tags = base_tags
|
|
51
|
+
self.batch_size = batch_size
|
|
52
|
+
self._buffer: List[Event] = []
|
|
53
|
+
self._lock = threading.Lock()
|
|
54
|
+
|
|
55
|
+
# -- recording ---------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
def record(self, event: Event) -> None:
|
|
58
|
+
"""Buffer a pre-built :class:`Event`, auto-flushing if the batch is full."""
|
|
59
|
+
with self._lock:
|
|
60
|
+
self._buffer.append(event)
|
|
61
|
+
should_flush = (
|
|
62
|
+
self.batch_size > 0 and len(self._buffer) >= self.batch_size
|
|
63
|
+
)
|
|
64
|
+
if should_flush:
|
|
65
|
+
self.flush()
|
|
66
|
+
|
|
67
|
+
def _emit(
|
|
68
|
+
self,
|
|
69
|
+
name: str,
|
|
70
|
+
kind: EventKind,
|
|
71
|
+
value: float | None,
|
|
72
|
+
attributes: Dict[str, Any],
|
|
73
|
+
) -> None:
|
|
74
|
+
merged = {**self.tags, "service": self.service, **attributes}
|
|
75
|
+
self.record(Event(name=name, kind=kind, value=value, attributes=merged))
|
|
76
|
+
|
|
77
|
+
def event(self, name: str, **attributes: Any) -> None:
|
|
78
|
+
"""Record a discrete event with optional attributes."""
|
|
79
|
+
self._emit(name, EventKind.EVENT, None, attributes)
|
|
80
|
+
|
|
81
|
+
def count(self, name: str, value: float = 1, **attributes: Any) -> None:
|
|
82
|
+
"""Record a counter increment (defaults to 1)."""
|
|
83
|
+
self._emit(name, EventKind.COUNT, value, attributes)
|
|
84
|
+
|
|
85
|
+
def gauge(self, name: str, value: float, **attributes: Any) -> None:
|
|
86
|
+
"""Record a point-in-time measurement."""
|
|
87
|
+
self._emit(name, EventKind.GAUGE, value, attributes)
|
|
88
|
+
|
|
89
|
+
# -- timing ------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
@contextmanager
|
|
92
|
+
def span(self, name: str, **attributes: Any) -> Iterator[Dict[str, Any]]:
|
|
93
|
+
"""Time a block of code and record its duration in seconds.
|
|
94
|
+
|
|
95
|
+
Yields the span's attribute dict so you can attach more metadata while
|
|
96
|
+
the block runs::
|
|
97
|
+
|
|
98
|
+
with tel.span("http.request") as attrs:
|
|
99
|
+
resp = do_request()
|
|
100
|
+
attrs["status"] = resp.status
|
|
101
|
+
"""
|
|
102
|
+
start = time.perf_counter()
|
|
103
|
+
span_attrs = dict(attributes)
|
|
104
|
+
try:
|
|
105
|
+
yield span_attrs
|
|
106
|
+
finally:
|
|
107
|
+
duration = time.perf_counter() - start
|
|
108
|
+
self._emit(name, EventKind.SPAN, duration, span_attrs)
|
|
109
|
+
|
|
110
|
+
def timed(self, name: Optional[str] = None, **attributes: Any) -> Callable[[F], F]:
|
|
111
|
+
"""Decorator that records how long the wrapped function takes.
|
|
112
|
+
|
|
113
|
+
Defaults the span name to ``"module.qualname"`` of the function::
|
|
114
|
+
|
|
115
|
+
@tel.timed()
|
|
116
|
+
def load_data():
|
|
117
|
+
...
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
def decorator(func: F) -> F:
|
|
121
|
+
span_name = name or f"{func.__module__}.{func.__qualname__}"
|
|
122
|
+
|
|
123
|
+
@functools.wraps(func)
|
|
124
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
125
|
+
with self.span(span_name, **attributes):
|
|
126
|
+
return func(*args, **kwargs)
|
|
127
|
+
|
|
128
|
+
return wrapper # type: ignore[return-value]
|
|
129
|
+
|
|
130
|
+
return decorator
|
|
131
|
+
|
|
132
|
+
# -- lifecycle ---------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
def flush(self) -> None:
|
|
135
|
+
"""Send all buffered events to every exporter and clear the buffer."""
|
|
136
|
+
with self._lock:
|
|
137
|
+
if not self._buffer:
|
|
138
|
+
return
|
|
139
|
+
batch = self._buffer
|
|
140
|
+
self._buffer = []
|
|
141
|
+
for exporter in self.exporters:
|
|
142
|
+
exporter.export(batch)
|
|
143
|
+
|
|
144
|
+
def close(self) -> None:
|
|
145
|
+
"""Flush remaining events and close every exporter."""
|
|
146
|
+
self.flush()
|
|
147
|
+
for exporter in self.exporters:
|
|
148
|
+
exporter.close()
|
|
149
|
+
|
|
150
|
+
def __enter__(self) -> "Telemetry":
|
|
151
|
+
return self
|
|
152
|
+
|
|
153
|
+
def __exit__(self, *exc: Any) -> None:
|
|
154
|
+
self.close()
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Event data model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import Any, Dict
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class EventKind(str, Enum):
|
|
12
|
+
"""The category of a telemetry event."""
|
|
13
|
+
|
|
14
|
+
EVENT = "event"
|
|
15
|
+
COUNT = "count"
|
|
16
|
+
GAUGE = "gauge"
|
|
17
|
+
SPAN = "span"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Event:
|
|
22
|
+
"""A single telemetry record.
|
|
23
|
+
|
|
24
|
+
Attributes:
|
|
25
|
+
name: Dotted event name, e.g. ``"user.signup"``.
|
|
26
|
+
kind: The :class:`EventKind` of this record.
|
|
27
|
+
value: Numeric payload — the increment for counts, the reading for
|
|
28
|
+
gauges, or the duration in seconds for spans. ``None`` for plain
|
|
29
|
+
events.
|
|
30
|
+
attributes: Arbitrary key/value metadata attached to the event.
|
|
31
|
+
timestamp: Unix epoch seconds when the event was created.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
name: str
|
|
35
|
+
kind: EventKind = EventKind.EVENT
|
|
36
|
+
value: float | None = None
|
|
37
|
+
attributes: Dict[str, Any] = field(default_factory=dict)
|
|
38
|
+
timestamp: float = field(default_factory=time.time)
|
|
39
|
+
|
|
40
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
41
|
+
"""Return a JSON-serializable representation of the event."""
|
|
42
|
+
return {
|
|
43
|
+
"name": self.name,
|
|
44
|
+
"kind": self.kind.value,
|
|
45
|
+
"value": self.value,
|
|
46
|
+
"attributes": self.attributes,
|
|
47
|
+
"timestamp": self.timestamp,
|
|
48
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Exporters decide where telemetry events end up."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from typing import List, TextIO
|
|
9
|
+
|
|
10
|
+
from .events import Event
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Exporter(ABC):
|
|
14
|
+
"""Base class for all exporters.
|
|
15
|
+
|
|
16
|
+
Subclass this and implement :meth:`export` to send events anywhere —
|
|
17
|
+
an HTTP endpoint, a log file, a message queue, and so on.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
def export(self, events: List[Event]) -> None:
|
|
22
|
+
"""Ship a batch of events. Called by ``Telemetry.flush()``."""
|
|
23
|
+
|
|
24
|
+
def close(self) -> None:
|
|
25
|
+
"""Release any resources. Called by ``Telemetry.close()``."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ConsoleExporter(Exporter):
|
|
29
|
+
"""Write each event as a line of JSON to a stream (stdout by default)."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, stream: TextIO | None = None) -> None:
|
|
32
|
+
self._stream = stream if stream is not None else sys.stdout
|
|
33
|
+
|
|
34
|
+
def export(self, events: List[Event]) -> None:
|
|
35
|
+
for event in events:
|
|
36
|
+
self._stream.write(json.dumps(event.to_dict()) + "\n")
|
|
37
|
+
self._stream.flush()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class MemoryExporter(Exporter):
|
|
41
|
+
"""Collect events in a list. Handy for tests and debugging."""
|
|
42
|
+
|
|
43
|
+
def __init__(self) -> None:
|
|
44
|
+
self.events: List[Event] = []
|
|
45
|
+
|
|
46
|
+
def export(self, events: List[Event]) -> None:
|
|
47
|
+
self.events.extend(events)
|
|
48
|
+
|
|
49
|
+
def clear(self) -> None:
|
|
50
|
+
self.events.clear()
|
|
File without changes
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Collect information about the host the code is running on.
|
|
2
|
+
|
|
3
|
+
Everything here uses only the standard library so the package stays
|
|
4
|
+
dependency-free. Collection is best-effort: if any probe fails, that key is
|
|
5
|
+
simply omitted rather than raising.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import platform
|
|
12
|
+
import socket
|
|
13
|
+
from typing import Any, Callable, Dict
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _safe(fn: Callable[[], Any]) -> Any | None:
|
|
17
|
+
"""Return ``fn()``, or ``None`` if it raises."""
|
|
18
|
+
try:
|
|
19
|
+
return fn()
|
|
20
|
+
except Exception:
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def sysinfo() -> Dict[str, Any]:
|
|
25
|
+
"""Return a dict describing the current host and Python runtime.
|
|
26
|
+
|
|
27
|
+
Keys (any that cannot be determined are left out):
|
|
28
|
+
|
|
29
|
+
- ``host``: network hostname
|
|
30
|
+
- ``os``: operating system name, e.g. ``"Linux"``, ``"Darwin"``, ``"Windows"``
|
|
31
|
+
- ``os_release``: OS release string
|
|
32
|
+
- ``arch``: machine architecture, e.g. ``"arm64"``, ``"x86_64"``
|
|
33
|
+
- ``python``: Python version, e.g. ``"3.12.1"``
|
|
34
|
+
- ``python_impl``: interpreter implementation, e.g. ``"CPython"``, ``"PyPy"``
|
|
35
|
+
- ``pid``: current process id
|
|
36
|
+
"""
|
|
37
|
+
probes: Dict[str, Callable[[], Any]] = {
|
|
38
|
+
"host": socket.gethostname,
|
|
39
|
+
"os": platform.system,
|
|
40
|
+
"os_release": platform.release,
|
|
41
|
+
"arch": platform.machine,
|
|
42
|
+
"python": platform.python_version,
|
|
43
|
+
"python_impl": platform.python_implementation,
|
|
44
|
+
"pid": os.getpid,
|
|
45
|
+
}
|
|
46
|
+
info: Dict[str, Any] = {}
|
|
47
|
+
for key, probe in probes.items():
|
|
48
|
+
value = _safe(probe)
|
|
49
|
+
if value not in (None, ""):
|
|
50
|
+
info[key] = value
|
|
51
|
+
return info
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from nanotelemetry import MemoryExporter, Telemetry, sysinfo
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_sysinfo_returns_expected_keys():
|
|
7
|
+
info = sysinfo()
|
|
8
|
+
# These are always determinable on any platform we support.
|
|
9
|
+
assert info["os"]
|
|
10
|
+
assert info["arch"]
|
|
11
|
+
assert info["python"]
|
|
12
|
+
assert info["pid"] == os.getpid()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_client_off_by_default():
|
|
16
|
+
tel = Telemetry(service="t")
|
|
17
|
+
assert "os" not in tel.tags
|
|
18
|
+
assert "host" not in tel.tags
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_include_system_info_populates_tags():
|
|
22
|
+
tel = Telemetry(service="t", include_system_info=True)
|
|
23
|
+
assert tel.tags["os"] == sysinfo()["os"]
|
|
24
|
+
assert tel.tags["pid"] == os.getpid()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_system_info_attached_to_every_event():
|
|
28
|
+
mem = MemoryExporter()
|
|
29
|
+
tel = Telemetry(service="t", exporters=[mem], include_system_info=True)
|
|
30
|
+
tel.event("hello")
|
|
31
|
+
tel.flush()
|
|
32
|
+
assert mem.events[0].attributes["os"] == sysinfo()["os"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_explicit_tags_override_system_info():
|
|
36
|
+
tel = Telemetry(
|
|
37
|
+
service="t",
|
|
38
|
+
tags={"os": "custom-os"},
|
|
39
|
+
include_system_info=True,
|
|
40
|
+
)
|
|
41
|
+
assert tel.tags["os"] == "custom-os"
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import time
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from nanotelemetry import Event, EventKind, MemoryExporter, Telemetry
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.fixture
|
|
9
|
+
def mem():
|
|
10
|
+
return MemoryExporter()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture
|
|
14
|
+
def tel(mem):
|
|
15
|
+
return Telemetry(service="test", exporters=[mem], tags={"env": "test"})
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_event_records_with_service_and_tags(tel, mem):
|
|
19
|
+
tel.event("user.signup", plan="pro")
|
|
20
|
+
tel.flush()
|
|
21
|
+
|
|
22
|
+
assert len(mem.events) == 1
|
|
23
|
+
ev = mem.events[0]
|
|
24
|
+
assert ev.name == "user.signup"
|
|
25
|
+
assert ev.kind is EventKind.EVENT
|
|
26
|
+
assert ev.attributes == {"service": "test", "env": "test", "plan": "pro"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_count_and_gauge(tel, mem):
|
|
30
|
+
tel.count("requests", 3, route="/home")
|
|
31
|
+
tel.gauge("queue.depth", 42)
|
|
32
|
+
tel.flush()
|
|
33
|
+
|
|
34
|
+
count, gauge = mem.events
|
|
35
|
+
assert count.kind is EventKind.COUNT and count.value == 3
|
|
36
|
+
assert gauge.kind is EventKind.GAUGE and gauge.value == 42
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_count_defaults_to_one(tel, mem):
|
|
40
|
+
tel.count("hits")
|
|
41
|
+
tel.flush()
|
|
42
|
+
assert mem.events[0].value == 1
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_span_records_duration(tel, mem):
|
|
46
|
+
with tel.span("work", step="load") as attrs:
|
|
47
|
+
attrs["ok"] = True
|
|
48
|
+
time.sleep(0.01)
|
|
49
|
+
tel.flush()
|
|
50
|
+
|
|
51
|
+
span = mem.events[0]
|
|
52
|
+
assert span.kind is EventKind.SPAN
|
|
53
|
+
assert span.value >= 0.01
|
|
54
|
+
assert span.attributes["step"] == "load"
|
|
55
|
+
assert span.attributes["ok"] is True
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_span_records_even_on_exception(tel, mem):
|
|
59
|
+
with pytest.raises(ValueError):
|
|
60
|
+
with tel.span("boom"):
|
|
61
|
+
raise ValueError("nope")
|
|
62
|
+
tel.flush()
|
|
63
|
+
assert mem.events[0].kind is EventKind.SPAN
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_timed_decorator(tel, mem):
|
|
67
|
+
@tel.timed("compute")
|
|
68
|
+
def add(a, b):
|
|
69
|
+
return a + b
|
|
70
|
+
|
|
71
|
+
assert add(2, 3) == 5
|
|
72
|
+
tel.flush()
|
|
73
|
+
assert mem.events[0].name == "compute"
|
|
74
|
+
assert mem.events[0].kind is EventKind.SPAN
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_auto_flush_on_batch_size(mem):
|
|
78
|
+
tel = Telemetry(service="t", exporters=[mem], batch_size=2)
|
|
79
|
+
tel.event("a")
|
|
80
|
+
assert mem.events == [] # not flushed yet
|
|
81
|
+
tel.event("b")
|
|
82
|
+
assert len(mem.events) == 2 # auto-flushed
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_flush_is_idempotent_when_empty(tel, mem):
|
|
86
|
+
tel.flush()
|
|
87
|
+
tel.flush()
|
|
88
|
+
assert mem.events == []
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_context_manager_flushes_on_exit(mem):
|
|
92
|
+
with Telemetry(service="t", exporters=[mem]) as tel:
|
|
93
|
+
tel.event("hello")
|
|
94
|
+
assert mem.events == []
|
|
95
|
+
assert len(mem.events) == 1
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_event_to_dict_roundtrip():
|
|
99
|
+
ev = Event(name="x", kind=EventKind.COUNT, value=1, attributes={"a": 1})
|
|
100
|
+
d = ev.to_dict()
|
|
101
|
+
assert d["name"] == "x"
|
|
102
|
+
assert d["kind"] == "count"
|
|
103
|
+
assert d["value"] == 1
|
|
104
|
+
assert d["attributes"] == {"a": 1}
|
|
105
|
+
assert "timestamp" in d
|