nullscope 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
nullscope/__init__.py ADDED
@@ -0,0 +1,327 @@
1
+ """Nullscope: Zero-cost telemetry for Python.
2
+
3
+ Provides ultra-low overhead no-op behavior when disabled and rich, contextual
4
+ metrics when enabled via environment flags.
5
+ """
6
+
7
+ import logging
8
+ import os
9
+ import re
10
+ import time
11
+ from collections import deque
12
+ from collections.abc import Iterator
13
+ from contextlib import AbstractContextManager, contextmanager
14
+ from contextvars import ContextVar
15
+ from dataclasses import dataclass
16
+ from types import TracebackType
17
+ from typing import Any, Final, Protocol, TypeAlias, TypedDict, runtime_checkable
18
+
19
+ log = logging.getLogger(__name__)
20
+
21
+ # Context-aware state for thread/async safety
22
+ _scope_stack_var: ContextVar[tuple[str, ...]] = ContextVar(
23
+ "scope_stack",
24
+ default=(),
25
+ )
26
+ _call_count_var: ContextVar[int] = ContextVar("call_count", default=0)
27
+
28
+ # Minimal-overhead optimization - evaluated once at import time
29
+ # Nullscope is enabled only via explicit env toggle to avoid unintended overhead
30
+ _NULLSCOPE_ENABLED = os.getenv("NULLSCOPE_ENABLED") == "1"
31
+ _STRICT_SCOPES = os.getenv("NULLSCOPE_STRICT") == "1"
32
+
33
+ # Built-in metadata keys (exported for clarity & resilience)
34
+ DEPTH: Final[str] = "depth"
35
+ PARENT_SCOPE: Final[str] = "parent_scope"
36
+ CALL_COUNT: Final[str] = "call_count"
37
+ METRIC_TYPE: Final[str] = "metric_type"
38
+ START_MONOTONIC_S: Final[str] = "start_monotonic_s" # High-precision monotonic seconds
39
+ END_MONOTONIC_S: Final[str] = "end_monotonic_s"
40
+ START_WALL_TIME_S: Final[str] = "start_wall_time_s" # Epoch seconds (correlation)
41
+ END_WALL_TIME_S: Final[str] = "end_wall_time_s"
42
+
43
+ _SCOPE_NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)*$")
44
+
45
+
46
+ class _TelemetryMetadata(TypedDict, total=False):
47
+ depth: int
48
+ call_count: int
49
+ parent_scope: str | None
50
+ start_monotonic_s: float
51
+ end_monotonic_s: float
52
+ start_wall_time_s: float
53
+ end_wall_time_s: float
54
+
55
+
56
+ @runtime_checkable
57
+ class TelemetryReporter(Protocol):
58
+ """Duck-typed protocol for telemetry reporters."""
59
+
60
+ def record_timing(self, scope: str, duration: float, **metadata: Any) -> None: ... # noqa: D102
61
+ def record_metric(self, scope: str, value: Any, **metadata: Any) -> None: ... # noqa: D102
62
+
63
+
64
+ @dataclass(frozen=True, slots=True)
65
+ class _NoOpTelemetryContext:
66
+ """An immutable and stateless no-op context, optimized for negligible overhead."""
67
+
68
+ def __call__(self, name: str, **metadata: Any) -> "_NoOpTelemetryContext": # noqa: ARG002
69
+ return self # Self is already a context manager
70
+
71
+ def __enter__(self) -> "_NoOpTelemetryContext":
72
+ return self
73
+
74
+ def __exit__(
75
+ self,
76
+ _: type[BaseException] | None,
77
+ __: BaseException | None,
78
+ ___: TracebackType | None,
79
+ ) -> bool | None:
80
+ return None
81
+
82
+ @property
83
+ def is_enabled(self) -> bool:
84
+ return False
85
+
86
+ def metric(self, name: str, value: Any, **metadata: Any) -> None:
87
+ pass
88
+
89
+ def time(self, name: str, **metadata: Any) -> "_NoOpTelemetryContext": # noqa: ARG002
90
+ return self
91
+
92
+ def count(self, name: str, increment: int = 1, **metadata: Any) -> None:
93
+ pass
94
+
95
+ def gauge(self, name: str, value: float, **metadata: Any) -> None:
96
+ pass
97
+
98
+
99
+ class _EnabledTelemetryContext:
100
+ """Full-featured telemetry context when enabled."""
101
+
102
+ __slots__ = ("reporters",)
103
+
104
+ def __init__(self, *reporters: TelemetryReporter):
105
+ self.reporters = reporters
106
+
107
+ def __call__(
108
+ self, name: str, **metadata: Any
109
+ ) -> AbstractContextManager["_EnabledTelemetryContext"]:
110
+ return self._create_scope(name, **metadata)
111
+
112
+ @contextmanager
113
+ def _create_scope(
114
+ self, name: str, **metadata: Any
115
+ ) -> Iterator["_EnabledTelemetryContext"]:
116
+ """The actual scope implementation when enabled."""
117
+ if not name or not isinstance(name, str):
118
+ raise ValueError("Scope name must be a non-empty string")
119
+ if _STRICT_SCOPES and not _SCOPE_NAME_PATTERN.fullmatch(name):
120
+ raise ValueError(
121
+ "Invalid scope name. Use lowercase letters, digits, and underscores; "
122
+ "segments separated by dots.",
123
+ )
124
+
125
+ scope_stack = _scope_stack_var.get()
126
+ call_count = _call_count_var.get()
127
+ scope_path = ".".join((*scope_stack, name))
128
+ start_monotonic_s = time.perf_counter()
129
+ start_wall_time_s = time.time()
130
+
131
+ scope_token = _scope_stack_var.set((*scope_stack, name))
132
+ count_token = _call_count_var.set(call_count + 1)
133
+
134
+ try:
135
+ yield self # Return self so chained methods work
136
+ finally:
137
+ end_monotonic_s = time.perf_counter()
138
+ duration = end_monotonic_s - start_monotonic_s
139
+ end_wall_time_s = start_wall_time_s + duration
140
+ _scope_stack_var.reset(scope_token)
141
+ _call_count_var.reset(count_token)
142
+
143
+ final_stack = _scope_stack_var.get()
144
+ final_count = _call_count_var.get()
145
+
146
+ built: _TelemetryMetadata = {
147
+ "depth": len(final_stack),
148
+ "call_count": final_count,
149
+ "parent_scope": ".".join(final_stack) if final_stack else None,
150
+ "start_monotonic_s": start_monotonic_s,
151
+ "end_monotonic_s": end_monotonic_s,
152
+ "start_wall_time_s": start_wall_time_s,
153
+ "end_wall_time_s": end_wall_time_s,
154
+ }
155
+ enhanced_metadata: dict[str, Any] = {**built, **metadata}
156
+
157
+ for reporter in self.reporters:
158
+ try:
159
+ reporter.record_timing(scope_path, duration, **enhanced_metadata)
160
+ except Exception as e:
161
+ log.error(
162
+ "Telemetry reporter '%s' failed: %s",
163
+ type(reporter).__name__,
164
+ e,
165
+ exc_info=True,
166
+ )
167
+
168
+ def metric(self, name: str, value: Any, **metadata: Any) -> None:
169
+ """Record a metric within current scope context."""
170
+ scope_stack = _scope_stack_var.get()
171
+ scope_path = ".".join((*scope_stack, name))
172
+ built: _TelemetryMetadata = {
173
+ "depth": len(scope_stack),
174
+ "parent_scope": ".".join(scope_stack) if scope_stack else None,
175
+ }
176
+ enhanced_metadata: dict[str, Any] = {**built, **metadata}
177
+ for reporter in self.reporters:
178
+ try:
179
+ reporter.record_metric(scope_path, value, **enhanced_metadata)
180
+ except Exception as e:
181
+ log.error(
182
+ "Telemetry reporter '%s' failed: %s",
183
+ type(reporter).__name__,
184
+ e,
185
+ exc_info=True,
186
+ )
187
+
188
+ # Convenience methods for chaining
189
+ def time(
190
+ self, name: str, **metadata: Any
191
+ ) -> AbstractContextManager["_EnabledTelemetryContext"]:
192
+ """Alias for scope() - more intuitive for timing operations."""
193
+ return self(name, **metadata)
194
+
195
+ def count(self, name: str, increment: int = 1, **metadata: Any) -> None:
196
+ """Record a counter metric."""
197
+ self.metric(name, increment, metric_type="counter", **metadata)
198
+
199
+ def gauge(self, name: str, value: float, **metadata: Any) -> None:
200
+ """Record a gauge metric."""
201
+ self.metric(name, value, metric_type="gauge", **metadata)
202
+
203
+ @property
204
+ def is_enabled(self) -> bool:
205
+ return True
206
+
207
+
208
+ _NO_OP_SINGLETON = _NoOpTelemetryContext()
209
+
210
+ TelemetryContextProtocol: TypeAlias = _EnabledTelemetryContext | _NoOpTelemetryContext
211
+
212
+
213
+ def TelemetryContext(*reporters: TelemetryReporter) -> TelemetryContextProtocol: # noqa: N802
214
+ """Return a telemetry context.
215
+
216
+ Behavior:
217
+ - When nullscope is enabled (``NULLSCOPE_ENABLED=1``), return an enabled
218
+ context. If no reporters are provided, install a default in-memory
219
+ ``SimpleReporter`` for convenience.
220
+ - When nullscope is disabled, return a shared no-op instance for negligible
221
+ overhead.
222
+ """
223
+ if _NULLSCOPE_ENABLED:
224
+ reps = reporters or (SimpleReporter(),)
225
+ return _EnabledTelemetryContext(*reps)
226
+ # Always return the same, pre-existing no-op instance.
227
+ return _NO_OP_SINGLETON
228
+
229
+
230
+ class SimpleReporter:
231
+ """Built-in reporter for development and debugging.
232
+
233
+ Collects telemetry in memory for inspection. Not intended for production use.
234
+ To view collected data, call `print_report()` or `get_report()`.
235
+ """
236
+
237
+ def __init__(self, max_entries_per_scope: int = 1000):
238
+ self.max_entries = max_entries_per_scope
239
+ self.timings: dict[str, deque[tuple[float, dict[str, Any]]]] = {}
240
+ self.metrics: dict[str, deque[tuple[Any, dict[str, Any]]]] = {}
241
+
242
+ def record_timing(self, scope: str, duration: float, **metadata: Any) -> None:
243
+ if scope not in self.timings:
244
+ self.timings[scope] = deque(maxlen=self.max_entries)
245
+ self.timings[scope].append((duration, metadata))
246
+
247
+ def record_metric(self, scope: str, value: Any, **metadata: Any) -> None:
248
+ if scope not in self.metrics:
249
+ self.metrics[scope] = deque(maxlen=self.max_entries)
250
+ self.metrics[scope].append((value, metadata))
251
+
252
+ def print_report(self) -> None:
253
+ """Prints the report to stdout if any data was collected."""
254
+ if self.timings or self.metrics:
255
+ print(self.get_report()) # noqa: T201
256
+
257
+ def reset(self) -> None:
258
+ """Clear all collected telemetry (testing convenience)."""
259
+ self.timings.clear()
260
+ self.metrics.clear()
261
+
262
+ def as_dict(self) -> dict[str, Any]:
263
+ """Return a JSON-serializable snapshot of collected data (testing)."""
264
+ return {
265
+ "timings": {key: list(values) for key, values in self.timings.items()},
266
+ "metrics": {key: list(values) for key, values in self.metrics.items()},
267
+ }
268
+
269
+ def get_report(self) -> str:
270
+ """Generate hierarchical telemetry report."""
271
+ lines = ["=== Nullscope Report ===\n"]
272
+
273
+ # Group by hierarchy
274
+ timing_tree = self._build_hierarchy(self.timings)
275
+ self._format_tree(timing_tree, lines, "Timings")
276
+
277
+ if self.metrics:
278
+ lines.append("\n--- Metrics ---")
279
+ for scope, values in sorted(self.metrics.items()):
280
+ total = sum(v[0] for v in values if isinstance(v[0], int | float))
281
+ lines.append(
282
+ f"{scope:<40} | Count: {len(values):<4} | Total: {total:,.0f}",
283
+ )
284
+
285
+ return "\n".join(lines)
286
+
287
+ def _build_hierarchy(
288
+ self,
289
+ data: dict[str, deque[tuple[float, dict[str, Any]]]],
290
+ ) -> dict[str, Any]:
291
+ """Build tree structure from dot-separated scope names."""
292
+ tree: dict[str, Any] = {}
293
+ for scope, values in data.items():
294
+ parts = scope.split(".")
295
+ current = tree
296
+ for part in parts[:-1]:
297
+ current = current.setdefault(part, {})
298
+ current[parts[-1]] = values
299
+ return tree
300
+
301
+ def _format_tree(
302
+ self,
303
+ tree: dict[str, Any],
304
+ lines: list[str],
305
+ title: str,
306
+ depth: int = 0,
307
+ ) -> None:
308
+ """Format hierarchical tree with indentation."""
309
+ if depth == 0:
310
+ lines.append(f"\n--- {title} ---")
311
+
312
+ for key, value in sorted(tree.items()):
313
+ indent = " " * depth
314
+ if isinstance(value, dict):
315
+ lines.append(f"{indent}{key}:")
316
+ self._format_tree(value, lines, title, depth + 1)
317
+ else:
318
+ # Leaf node - actual timing data
319
+ durations = [v[0] for v in value]
320
+ avg_time = sum(durations) / len(durations)
321
+ total_time = sum(durations)
322
+ lines.append(
323
+ f"{indent}{key:<30} | "
324
+ f"Calls: {len(durations):<4} | "
325
+ f"Avg: {avg_time:.4f}s | "
326
+ f"Total: {total_time:.4f}s",
327
+ )
@@ -0,0 +1,103 @@
1
+ """OpenTelemetry adapter for Nullscope reporters.
2
+
3
+ This adapter intentionally keeps a small surface area:
4
+ - timings -> Histogram (seconds), optional synthetic Span
5
+ - counters -> Counter
6
+ - gauges -> Histogram (sampled values distribution)
7
+ """
8
+
9
+ from collections.abc import Mapping
10
+ from typing import Any
11
+
12
+ from nullscope import TelemetryReporter
13
+
14
+ try:
15
+ from opentelemetry import metrics, trace
16
+ except ModuleNotFoundError: # pragma: no cover - exercised in runtime environments
17
+ metrics = None # type: ignore[assignment]
18
+ trace = None # type: ignore[assignment]
19
+
20
+
21
+ def _sanitize_attributes(
22
+ attributes: Mapping[str, Any],
23
+ ) -> dict[str, bool | str | bytes | int | float]:
24
+ """Convert metadata into OpenTelemetry-compatible attribute values."""
25
+ out: dict[str, bool | str | bytes | int | float] = {}
26
+ for key, value in attributes.items():
27
+ if value is None:
28
+ continue
29
+ if isinstance(value, bool | str | bytes | int | float):
30
+ out[key] = value
31
+ continue
32
+ out[key] = str(value)
33
+ return out
34
+
35
+
36
+ class OTelReporter(TelemetryReporter):
37
+ """Minimal OpenTelemetry adapter for metrics-first telemetry export."""
38
+
39
+ def __init__(self, service_name: str = "nullscope"):
40
+ """Create a reporter bound to the given OpenTelemetry service name."""
41
+ if trace is None or metrics is None:
42
+ raise RuntimeError(
43
+ "OpenTelemetry is not installed. "
44
+ "Install optional dependency: nullscope[otel]",
45
+ )
46
+ self.tracer = trace.get_tracer(service_name)
47
+ self.meter = metrics.get_meter(service_name)
48
+ self._histograms: dict[tuple[str, str], Any] = {}
49
+ self._counters: dict[str, Any] = {}
50
+
51
+ def record_timing(self, scope: str, duration: float, **metadata: Any) -> None:
52
+ """Record timing as a histogram sample and optional synthetic span."""
53
+ attributes = _sanitize_attributes(metadata)
54
+ self._get_timing_histogram(scope).record(duration, attributes=attributes)
55
+
56
+ start_s = metadata.get("start_wall_time_s")
57
+ end_s = metadata.get("end_wall_time_s")
58
+ if isinstance(start_s, int | float) and isinstance(end_s, int | float):
59
+ start_ns = int(start_s * 1e9)
60
+ end_ns = int(end_s * 1e9)
61
+ if end_ns >= start_ns:
62
+ span = self.tracer.start_span(
63
+ scope,
64
+ start_time=start_ns,
65
+ attributes=attributes,
66
+ )
67
+ span.end(end_time=end_ns)
68
+
69
+ def record_metric(self, scope: str, value: Any, **metadata: Any) -> None:
70
+ """Record `counter` and `gauge` metrics using OpenTelemetry instruments."""
71
+ metric_type = metadata.get("metric_type", "counter")
72
+ attributes = _sanitize_attributes(metadata)
73
+
74
+ if metric_type == "counter" and isinstance(value, int | float) and value >= 0:
75
+ self._get_counter(scope).add(value, attributes=attributes)
76
+ return
77
+
78
+ if metric_type == "gauge" and isinstance(value, int | float):
79
+ # OTel sync Gauge support is limited in Python; record value samples instead.
80
+ self._get_value_histogram(scope).record(value, attributes=attributes)
81
+
82
+ def _get_timing_histogram(self, scope: str) -> Any:
83
+ key = ("timing", scope)
84
+ histogram = self._histograms.get(key)
85
+ if histogram is None:
86
+ histogram = self.meter.create_histogram(scope, unit="s")
87
+ self._histograms[key] = histogram
88
+ return histogram
89
+
90
+ def _get_value_histogram(self, scope: str) -> Any:
91
+ key = ("value", scope)
92
+ histogram = self._histograms.get(key)
93
+ if histogram is None:
94
+ histogram = self.meter.create_histogram(scope)
95
+ self._histograms[key] = histogram
96
+ return histogram
97
+
98
+ def _get_counter(self, scope: str) -> Any:
99
+ counter = self._counters.get(scope)
100
+ if counter is None:
101
+ counter = self.meter.create_counter(scope)
102
+ self._counters[scope] = counter
103
+ return counter
nullscope/py.typed ADDED
File without changes
@@ -0,0 +1,190 @@
1
+ Metadata-Version: 2.4
2
+ Name: nullscope
3
+ Version: 0.1.0
4
+ Summary: Zero-cost telemetry for Python. No-op when disabled, rich context when enabled.
5
+ Project-URL: Homepage, https://github.com/seanbrar/nullscope
6
+ Project-URL: Repository, https://github.com/seanbrar/nullscope
7
+ Project-URL: Documentation, https://github.com/seanbrar/nullscope/tree/main/docs
8
+ Project-URL: Issues, https://github.com/seanbrar/nullscope/issues
9
+ Author-email: Sean Brar <hello@seanbrar.com>
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: metrics,nullscope,observability,telemetry,tracing,zero-cost
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3.14
23
+ Classifier: Topic :: Software Development :: Libraries
24
+ Classifier: Topic :: System :: Monitoring
25
+ Classifier: Typing :: Typed
26
+ Requires-Python: <3.15,>=3.10
27
+ Provides-Extra: otel
28
+ Requires-Dist: opentelemetry-api>=1.20.0; extra == 'otel'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # Nullscope
32
+
33
+ [![PyPI](https://img.shields.io/pypi/v/nullscope)](https://pypi.org/project/nullscope/)
34
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
35
+
36
+ Zero-cost telemetry for Python. No-op when disabled, rich context when enabled.
37
+
38
+ ## Why Nullscope?
39
+
40
+ Most telemetry libraries have runtime cost even when you don't need them. Nullscope is different:
41
+
42
+ - **Disabled**: Returns a singleton no-op object. No allocations, no timing calls, no overhead.
43
+ - **Enabled**: Full-featured timing and metrics with automatic scope hierarchy.
44
+
45
+ This makes Nullscope ideal for **libraries** (users can enable telemetry if they want) and **applications** where you want zero production overhead but rich debugging capability.
46
+
47
+ ```python
48
+ from nullscope import TelemetryContext
49
+
50
+ # When NULLSCOPE_ENABLED != "1", this is literally just returning a cached object
51
+ telemetry = TelemetryContext()
52
+
53
+ with telemetry("database.query"): # No-op when disabled
54
+ results = db.execute(query)
55
+ ```
56
+
57
+ ## What Nullscope Is Not
58
+
59
+ - **A distributed tracing system.** No trace propagation, no span IDs, no context injection for cross-service correlation. If you need that, use OpenTelemetry directly. Nullscope can *feed* OTel, but it doesn't replace it.
60
+
61
+ - **A metrics aggregation layer.** Nullscope reports raw events to reporters. It doesn't compute percentiles, histograms, or roll up data. That's the reporter's job (or the backend's).
62
+
63
+ - **Auto-instrumentation.** Nullscope won't patch your HTTP client or database driver. You instrument what you want, explicitly.
64
+
65
+ - **A logging framework.** Scopes are for timing and metrics, not structured log events. (Though a reporter *could* emit logs.)
66
+
67
+ ## Installation
68
+
69
+ ```bash
70
+ pip install nullscope
71
+ ```
72
+
73
+ With OpenTelemetry support:
74
+
75
+ ```bash
76
+ pip install nullscope[otel]
77
+ ```
78
+
79
+ ## Quick Start
80
+
81
+ ```python
82
+ import os
83
+ os.environ["NULLSCOPE_ENABLED"] = "1" # Enable telemetry
84
+
85
+ from nullscope import TelemetryContext, SimpleReporter
86
+
87
+ # Create a reporter to see output
88
+ reporter = SimpleReporter()
89
+ telemetry = TelemetryContext(reporter)
90
+
91
+ # Time operations with automatic hierarchy
92
+ with telemetry("request"):
93
+ with telemetry("auth"):
94
+ validate_token()
95
+
96
+ with telemetry("handler"):
97
+ process_data()
98
+
99
+ # See what was collected
100
+ reporter.print_report()
101
+ ```
102
+
103
+ Output:
104
+
105
+ ```text
106
+ === Nullscope Report ===
107
+
108
+ --- Timings ---
109
+ request:
110
+ auth | Calls: 1 | Avg: 0.0012s | Total: 0.0012s
111
+ handler | Calls: 1 | Avg: 0.0234s | Total: 0.0234s
112
+ ```
113
+
114
+ ## Configuration
115
+
116
+ | Environment Variable | Description |
117
+ | --------------------- | ------------------------------------ |
118
+ | `NULLSCOPE_ENABLED=1` | Enable telemetry (default: disabled) |
119
+
120
+ Note: environment flags are read at import time. In tests, reload `nullscope` after changing env vars.
121
+
122
+ ## API
123
+
124
+ ### TelemetryContext
125
+
126
+ ```python
127
+ from nullscope import TelemetryContext
128
+
129
+ telemetry = TelemetryContext() # Uses default SimpleReporter when enabled
130
+ telemetry = TelemetryContext(my_reporter) # Custom reporter
131
+ telemetry = TelemetryContext(reporter1, reporter2) # Multiple reporters
132
+ ```
133
+
134
+ ### Scopes (Timing)
135
+
136
+ ```python
137
+ with telemetry("operation"):
138
+ do_work()
139
+
140
+ # With metadata
141
+ with telemetry("http.request", method="GET", path="/api/users"):
142
+ handle_request()
143
+ ```
144
+
145
+ ### Metrics
146
+
147
+ ```python
148
+ telemetry.count("cache.hit") # Increment counter
149
+ telemetry.count("items.processed", 5) # Increment by N
150
+ telemetry.gauge("queue.depth", len(queue)) # Point-in-time value
151
+ telemetry.metric("custom", value, metric_type="counter") # Generic
152
+ ```
153
+
154
+ ### Check Status
155
+
156
+ ```python
157
+ if telemetry.is_enabled:
158
+ # Do expensive debug logging
159
+ pass
160
+ ```
161
+
162
+ ## OpenTelemetry Adapter
163
+
164
+ Export to OpenTelemetry backends:
165
+
166
+ ```python
167
+ from nullscope import TelemetryContext
168
+ from nullscope.adapters.opentelemetry import OTelReporter
169
+
170
+ # Configure OTel SDK first (providers, exporters, etc.)
171
+ # Then use Nullscope with OTel reporter
172
+ telemetry = TelemetryContext(OTelReporter(service_name="my-service"))
173
+ ```
174
+
175
+ The adapter emits:
176
+
177
+ - **Timings** → Histogram (seconds) + synthetic Span when wall-clock bounds are present
178
+ - **Counters** → Counter
179
+ - **Gauges** → Histogram (sampled values, since Python OTel sync gauge support is limited)
180
+
181
+ ## Documentation
182
+
183
+ - [Design](docs/design.md) - Architecture and implementation details
184
+ - [Examples](docs/examples.md) - Real-world usage patterns
185
+ - [Comparison](docs/comparison.md) - When to use Nullscope vs alternatives
186
+ - [Roadmap](ROADMAP.md) - Version milestones and planned features
187
+
188
+ ## License
189
+
190
+ [MIT](LICENSE)
@@ -0,0 +1,7 @@
1
+ nullscope/__init__.py,sha256=MtjVLpIm1l5wQNSqOIKcNXjTXRX8dtl8MldfWn4ZxTs,11911
2
+ nullscope/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ nullscope/adapters/opentelemetry.py,sha256=beyAyDP8IsGj_ZSlT24PMjcR8QLEDzYoQBn0-EZQN90,4080
4
+ nullscope-0.1.0.dist-info/METADATA,sha256=8VCMRVWZ-c5UZRrTJAtwZey7Ys1rAKmM1p6CkVOjJfI,5898
5
+ nullscope-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
6
+ nullscope-0.1.0.dist-info/licenses/LICENSE,sha256=C1VP2iuJoqm33UVgrb-73a-ofh40p4zf2vXbs8jubDk,1066
7
+ nullscope-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sean Brar
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.