nanotelemetry 0.2.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.
@@ -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()
nanotelemetry/py.typed ADDED
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,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,10 @@
1
+ nanotelemetry/__init__.py,sha256=Y5W5Itjn8gx9rJTRHYsiJkLqZyL1V5LpcWG_e8QXbNQ,849
2
+ nanotelemetry/client.py,sha256=bV3hs_PMOYSedNu_acc6JpuUObt5nJS8yYnbywQ-6JQ,5531
3
+ nanotelemetry/events.py,sha256=kUt9qMceE_JXjrasQIOw8sFqkzEgortYPi3zMNiEwGQ,1351
4
+ nanotelemetry/exporters.py,sha256=ZGg8KsrGcuhJWniw73lpKtexNjhcRNidMmQjGYXxTkU,1396
5
+ nanotelemetry/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ nanotelemetry/sysinfo.py,sha256=7B_L74GjIM-GQ9MFb3kw1a_80iCquce9OHAiT0-DscU,1594
7
+ nanotelemetry-0.2.0.dist-info/METADATA,sha256=5mKewjbh5DPbkl25T77ZDcdoXKxGF1FCZ86jDqttcsw,4091
8
+ nanotelemetry-0.2.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
9
+ nanotelemetry-0.2.0.dist-info/licenses/LICENSE,sha256=L6lI2mYr7FeulAfspovHoWyb1GQmSzl0QgZZvMWDXVU,1083
10
+ nanotelemetry-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.