Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,3 @@ __pycache__
.pytest_cache
.mypy_cache
.DS_Store
uv.lock
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ request:
| Environment Variable | Description |
| --------------------- | ------------------------------------ |
| `NULLSCOPE_ENABLED=1` | Enable telemetry (default: disabled) |
| `NULLSCOPE_STRICT=1` | Enforce strict dotted scope names |

Note: environment flags are read at import time. In tests, reload `nullscope` after changing env vars.

Expand All @@ -112,6 +113,18 @@ with telemetry("http.request", method="GET", path="/api/users"):
handle_request()
```

### Decorators

```python
@telemetry.timed("http.handler")
def handle() -> None:
process_request()

@telemetry.timed("db.query", table="users")
async def fetch_users() -> list[dict]:
return await db.fetch_all()
```

### Metrics

```python
Expand All @@ -129,6 +142,28 @@ if telemetry.is_enabled:
pass
```

### Reporter Lifecycle

```python
# Flush buffered reporters (if they implement flush())
telemetry.flush()

# Shutdown reporters cleanly (if they implement shutdown())
telemetry.shutdown()
```

### Async Safety

Nullscope uses `contextvars`, so each async task keeps its own scope stack without cross-talk:

```python
import asyncio

async def worker(task_id: int):
with telemetry("task", task_id=task_id):
await asyncio.sleep(0.1)
```

## OpenTelemetry Adapter

Export to OpenTelemetry backends:
Expand Down
14 changes: 7 additions & 7 deletions ROADMAP.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Nullscope Roadmap

## Version 0.1.0 (Current)
## Version 0.1.0 (Completed)

- [x] Zero-cost no-op pattern
- [x] Enabled context with scope hierarchy
Expand All @@ -11,12 +11,13 @@
- [x] `py.typed` marker for PEP 561
- [x] Basic test coverage

## Version 0.2.0 - Ergonomics
## Version 0.2.0 (Current) - Ergonomics

- [ ] Decorator support (`@nullscope.timed("operation")`)
- [ ] Reporter lifecycle methods (`flush()`, `shutdown()`)
- [ ] Async documentation and explicit testing
- [ ] Improved error messages
- [x] Decorator support (`@telemetry.timed("operation")`)
- [x] Reporter lifecycle methods (`flush()`, `shutdown()`)
- [x] Async documentation and explicit testing
- [x] Improved error messages
- [x] Performance benchmarks (baseline + optimization validation)

## Version 0.3.0 - Observability

Expand All @@ -28,7 +29,6 @@

- [ ] Real-world usage feedback incorporated
- [ ] API refinements based on feedback
- [ ] Performance benchmarks

## Version 1.0.0 - Stable

Expand Down
76 changes: 76 additions & 0 deletions benchmarks/BASELINE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Benchmark Results

Python: 3.14.2
Platform: darwin (macOS)

## Reference Measurements

| Benchmark | ns/call |
| ----------- | --------- |
| `time.perf_counter()` | ~80 |
| Empty function call | ~33 |
| Minimal context manager | ~120 |

---

## v0.1.0 Baseline (2026-02-04)

### Disabled Mode (No-op)

| Benchmark | ns/call |
| ----------- | --------- |
| scope entry/exit | 180.4 |
| 3 nested scopes | 485.6 |
| timed() decorator creation | 126.6 |
| timed() decorated call | 34.5 |
| metric() call | 89.9 |

### Enabled Mode

| Benchmark | ns/call |
| ----------- | --------- |
| scope entry/exit | 3,033.5 |
| 3 nested scopes | 9,054.4 |
| 5 nested scopes | 15,611.3 |
| timed() decorated call | 2,908.7 |
| metric() call | 760.8 |
| scope with 2 metadata keys | 3,326.2 |

---

## v0.2.0 Optimizations (2026-02-04)

Changes:

- Replaced `@contextmanager` with explicit `_Scope` class (`__enter__`/`__exit__`)
- Identity function singleton for no-op `timed()` (eliminates lambda allocation)
- Single tuple creation per scope entry
- Optimized `metric()` to avoid redundant `.join()` calls

### Disabled Mode (No-op)

| Benchmark | v0.1.0 | v0.2.0 | Change |
| ----------- | -------- | -------- | -------- |
| scope entry/exit | 180.4 | 181.4 | — |
| 3 nested scopes | 485.6 | 478.8 | -1% |
| timed() decorator creation | 126.6 | 88.7 | **-30%** |
| timed() decorated call | 34.5 | 32.9 | -5% |
| metric() call | 89.9 | 80.9 | -10% |

### Enabled Mode

| Benchmark | v0.1.0 | v0.2.0 | Change |
| ----------- | -------- | -------- | -------- |
| scope entry/exit | 3,033.5 | 2,531.1 | **-17%** |
| 5 nested scopes | 15,611.3 | 13,160.2 | **-16%** |
| timed() decorated call | 2,908.7 | 2,295.6 | **-21%** |
| metric() call | 760.8 | 691.6 | **-9%** |
| scope with 2 metadata keys | 3,326.2 | 2,346.1 | **-29%** |

---

## Future Optimization Candidates

1. **Validation caching**: Cache validated scope names in a set for repeated calls
2. **Empty metadata short-circuit**: Skip dict merge when user metadata is empty
3. **Disabled scope overhead**: Still ~60 ns above minimal CM baseline (acceptable)
Loading