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.
@@ -0,0 +1,21 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+
9
+ # Packaging / envs
10
+ .venv/
11
+ venv/
12
+
13
+ # Testing
14
+ .pytest_cache/
15
+ .coverage
16
+ htmlcov/
17
+
18
+ # Editors / OS
19
+ .vscode/
20
+ .idea/
21
+ .DS_Store
@@ -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