Skip to content

Commit 5245bc8

Browse files
committed
add before_send hook and sampling
1 parent a767fad commit 5245bc8

7 files changed

Lines changed: 143 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.9.6] - 2026-06-11
9+
10+
### Added
11+
12+
- `ClientOptions.before_send`: hook called before buffering — mutate the entry or return `None` to drop it. A raising hook never loses the entry
13+
- `ClientOptions.sample_rate` (0.0–1.0, default 1.0): per-entry sampling applied after `before_send`
14+
815
## [0.9.5] - 2026-06-11
916

1017
### Changed

logtide_sdk/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,5 @@
44
import it without circular imports. Bump together with ``pyproject.toml``.
55
"""
66

7-
VERSION = "0.9.5"
7+
VERSION = "0.9.6"
88
SDK_NAME = "logtide-python"

logtide_sdk/async_client.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import asyncio
44
import dataclasses
55
import json
6+
import random
67
import time
78
from collections.abc import Callable
89
from threading import Lock as ThreadingLock
@@ -176,6 +177,23 @@ async def log(self, entry: LogEntry) -> None:
176177
if "sdk" not in entry.metadata:
177178
entry.metadata["sdk"] = {"name": SDK_NAME, "version": VERSION}
178179

180+
# before_send hook: may mutate or drop the entry. A buggy hook must
181+
# never lose the entry or raise to the caller.
182+
if self.options.before_send is not None:
183+
try:
184+
result = self.options.before_send(entry)
185+
except Exception as hook_error:
186+
if self.options.debug:
187+
print(f"[LogTide] before_send raised, keeping entry: {hook_error}")
188+
else:
189+
if result is None:
190+
return
191+
entry = result
192+
193+
# Sampling (applied after before_send, spec 005 §5)
194+
if self.options.sample_rate < 1.0 and random.random() > self.options.sample_rate:
195+
return
196+
179197
self._apply_payload_limits(entry)
180198

181199
should_flush = False

logtide_sdk/client.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import atexit
44
import dataclasses
55
import json
6+
import random
67
import re
78
import time
89
import traceback
@@ -271,6 +272,23 @@ def log(self, entry: LogEntry) -> None:
271272
if "sdk" not in entry.metadata:
272273
entry.metadata["sdk"] = {"name": SDK_NAME, "version": VERSION}
273274

275+
# before_send hook: may mutate or drop the entry. A buggy hook must
276+
# never lose the entry or raise to the caller.
277+
if self.options.before_send is not None:
278+
try:
279+
result = self.options.before_send(entry)
280+
except Exception as hook_error:
281+
if self.options.debug:
282+
print(f"[LogTide] before_send raised, keeping entry: {hook_error}")
283+
else:
284+
if result is None:
285+
return
286+
entry = result
287+
288+
# Sampling (applied after before_send, spec 005 §5)
289+
if self.options.sample_rate < 1.0 and random.random() > self.options.sample_rate:
290+
return
291+
274292
# Apply payload limits before buffering
275293
self._apply_payload_limits(entry)
276294

logtide_sdk/models.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Data models for LogTide SDK."""
22

3+
from collections.abc import Callable
34
from dataclasses import dataclass, field
45
from datetime import datetime, timezone
56
from typing import Any
@@ -79,8 +80,12 @@ class ClientOptions:
7980
payload_limits: PayloadLimitsOptions | None = None
8081
dsn: str | None = None
8182
service: str | None = None
83+
before_send: Callable[["LogEntry"], "LogEntry | None"] | None = None
84+
sample_rate: float = 1.0
8285

8386
def __post_init__(self) -> None:
87+
if not 0.0 <= self.sample_rate <= 1.0:
88+
raise ValueError("sample_rate must be between 0.0 and 1.0")
8489
if self.dsn:
8590
parts = parse_dsn(self.dsn)
8691
if not self.api_url:

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "logtide-sdk"
7-
version = "0.9.5"
7+
version = "0.9.6"
88
description = "Official Python SDK for LogTide - Self-hosted log management with async client, logging integration, batching, retry, circuit breaker, and middleware"
99
readme = "README.md"
1010
license = { text = "MIT" }

tests/test_hooks.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"""before_send hook and sampling (conformance C22/C23)."""
2+
3+
import pytest
4+
5+
from logtide_sdk import ClientOptions, LogTideClient
6+
7+
8+
def make_client(**kwargs):
9+
return LogTideClient(
10+
ClientOptions(api_url="http://localhost:8080", api_key="lp_k", service="svc", **kwargs)
11+
)
12+
13+
14+
def test_before_send_can_mutate(mocker):
15+
def scrub(entry):
16+
entry.metadata["password"] = "[redacted]"
17+
return entry
18+
19+
client = make_client(before_send=scrub)
20+
try:
21+
client.info("login", {"password": "hunter2"})
22+
assert client._buffer[-1].metadata["password"] == "[redacted]"
23+
finally:
24+
client._closed = True
25+
26+
27+
def test_before_send_can_drop():
28+
client = make_client(before_send=lambda entry: None)
29+
try:
30+
client.info("dropped")
31+
assert len(client._buffer) == 0
32+
assert client.get_metrics().logs_sent == 0
33+
finally:
34+
client._closed = True
35+
36+
37+
def test_before_send_exception_does_not_break_capture():
38+
def broken(entry):
39+
raise RuntimeError("hook bug")
40+
41+
client = make_client(before_send=broken)
42+
try:
43+
client.info("survives")
44+
# a buggy hook must not lose the entry or raise to the caller
45+
assert len(client._buffer) == 1
46+
finally:
47+
client._closed = True
48+
49+
50+
def test_sample_rate_zero_sends_nothing():
51+
client = make_client(sample_rate=0.0)
52+
try:
53+
for _ in range(20):
54+
client.info("nope")
55+
assert len(client._buffer) == 0
56+
finally:
57+
client._closed = True
58+
59+
60+
def test_sample_rate_one_sends_everything():
61+
client = make_client(sample_rate=1.0)
62+
try:
63+
for _ in range(5):
64+
client.info("yes")
65+
assert len(client._buffer) == 5
66+
finally:
67+
client._closed = True
68+
69+
70+
def test_sample_rate_validation():
71+
with pytest.raises(ValueError):
72+
ClientOptions(api_url="x://h", api_key="k", sample_rate=1.5)
73+
with pytest.raises(ValueError):
74+
ClientOptions(api_url="x://h", api_key="k", sample_rate=-0.1)
75+
76+
77+
@pytest.mark.asyncio
78+
async def test_async_before_send_and_sampling():
79+
from logtide_sdk.async_client import AsyncLogTideClient
80+
81+
client = AsyncLogTideClient(
82+
ClientOptions(
83+
api_url="http://localhost:8080",
84+
api_key="lp_k",
85+
service="svc",
86+
before_send=lambda e: None,
87+
)
88+
)
89+
try:
90+
await client.info("dropped")
91+
assert len(client._buffer) == 0
92+
finally:
93+
client._closed = True

0 commit comments

Comments
 (0)