Skip to content

Commit 4b468a8

Browse files
authored
Merge pull request #7 from apelsynca/feat/no-send-mode
Feat: local mode, and allowing empty/unset API key to be passed for local_mode
2 parents 32a2c84 + cdac5d0 commit 4b468a8

21 files changed

Lines changed: 594 additions & 377 deletions

.python-version

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
3.10

logtide_sdk/__init__.py

Lines changed: 28 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,20 @@
11
"""LogTide SDK - Official Python SDK for LogTide."""
22

3-
from logtide_sdk.client import LogTideClient, serialize_exception
3+
from logtide_sdk.client import LogTideClient
44
from logtide_sdk.dsn import DsnParseError, DsnParts, parse_dsn
5+
from logtide_sdk.enums import CircuitState, LogLevel
6+
from logtide_sdk.exceptions import BufferFullError, CircuitBreakerOpenError, LogTideError
7+
from logtide_sdk.handler import LogTideHandler
8+
from logtide_sdk.models import (
9+
AggregatedStatsOptions,
10+
AggregatedStatsResponse,
11+
ClientMetrics,
12+
ClientOptions,
13+
LogEntry,
14+
LogsResponse,
15+
PayloadLimitsOptions,
16+
QueryOptions,
17+
)
518
from logtide_sdk.scope import (
619
Breadcrumb,
720
Scope,
@@ -14,28 +27,16 @@
1427
set_tag,
1528
set_user,
1629
)
30+
from logtide_sdk.serialization import serialize_exception
1731
from logtide_sdk.tracecontext import (
1832
TraceContext,
1933
format_traceparent,
2034
generate_span_id,
21-
inject_traceparent,
2235
generate_trace_id,
36+
inject_traceparent,
2337
parse_traceparent,
2438
resolve_trace_id,
2539
)
26-
from logtide_sdk.enums import CircuitState, LogLevel
27-
from logtide_sdk.exceptions import BufferFullError, CircuitBreakerOpenError, LogTideError
28-
from logtide_sdk.handler import LogTideHandler
29-
from logtide_sdk.models import (
30-
AggregatedStatsOptions,
31-
AggregatedStatsResponse,
32-
ClientMetrics,
33-
ClientOptions,
34-
LogEntry,
35-
LogsResponse,
36-
PayloadLimitsOptions,
37-
QueryOptions,
38-
)
3940

4041
_has_async = False
4142
try:
@@ -49,41 +50,41 @@
4950

5051
__all__ = [
5152
"AggregatedStatsOptions",
52-
"Breadcrumb",
53-
"Scope",
54-
"User",
55-
"add_breadcrumb",
56-
"get_current_scope",
57-
"push_scope",
58-
"set_extra",
59-
"set_session_id",
60-
"set_tag",
61-
"set_user",
6253
"AggregatedStatsResponse",
54+
"Breadcrumb",
6355
"BufferFullError",
6456
"CircuitBreakerOpenError",
6557
"CircuitState",
6658
"ClientMetrics",
6759
"ClientOptions",
60+
"DsnParseError",
61+
"DsnParts",
6862
"LogEntry",
6963
"LogLevel",
7064
"LogTideClient",
7165
"LogTideError",
7266
"LogTideHandler",
7367
"LogsResponse",
7468
"PayloadLimitsOptions",
75-
"DsnParseError",
76-
"DsnParts",
7769
"QueryOptions",
70+
"Scope",
7871
"TraceContext",
72+
"User",
73+
"add_breadcrumb",
7974
"format_traceparent",
8075
"generate_span_id",
8176
"generate_trace_id",
77+
"get_current_scope",
8278
"inject_traceparent",
8379
"parse_dsn",
8480
"parse_traceparent",
81+
"push_scope",
8582
"resolve_trace_id",
8683
"serialize_exception",
84+
"set_extra",
85+
"set_session_id",
86+
"set_tag",
87+
"set_user",
8788
]
8889

8990
if _has_async:

logtide_sdk/_base_client.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
from typing import Any
2+
3+
from logtide_sdk.json_encoder import logtide_json_dumps
4+
from logtide_sdk.models import ClientOptions, LogEntry, PayloadLimitsOptions
5+
from logtide_sdk.payload_limits import apply_payload_limits
6+
from logtide_sdk.scope import get_current_scope
7+
from logtide_sdk.serialization import serialize_exception
8+
from logtide_sdk.tracecontext import active_trace_context, generate_trace_id
9+
10+
11+
class BaseClient:
12+
"""
13+
Base LogTide SDK Client. (more like helper to be DRY for now)
14+
"""
15+
16+
def __init__(self, options: ClientOptions) -> None:
17+
if not options.local_mode and not options.api_key:
18+
raise RuntimeError(
19+
f"Cannot instantiate {self.__class__.__name__} without api_key with disabled local_mode"
20+
)
21+
22+
self.options = options
23+
24+
self._payload_limits = options.payload_limits or PayloadLimitsOptions()
25+
self._trace_id: str | None = None
26+
self._closed = False
27+
28+
def set_trace_id(self, trace_id: str | None) -> None:
29+
"""Set trace ID for subsequent logs."""
30+
self._trace_id = trace_id
31+
32+
def get_trace_id(self) -> str | None:
33+
"""Return the current trace ID."""
34+
return self._trace_id
35+
36+
def _get_headers(self) -> dict[str, str]:
37+
"""Return HTTP headers for all API requests."""
38+
assert self.options.api_key, "Get headers somehow with unset API Key"
39+
40+
return {
41+
"X-API-Key": self.options.api_key,
42+
"Content-Type": "application/json",
43+
}
44+
45+
def _apply_payload_limits(self, entry: LogEntry) -> None:
46+
"""Enforce payload limits on entry.metadata in-place."""
47+
if not entry.metadata:
48+
return
49+
50+
lim = self._payload_limits
51+
entry.metadata = apply_payload_limits(entry.metadata, "root", lim)
52+
53+
raw = logtide_json_dumps(entry)
54+
if len(raw.encode()) > lim.max_log_size:
55+
if self.options.debug:
56+
# TODO: replace all prints with logging
57+
print(f"[LogTide] Log entry too large ({len(raw)} bytes), truncating metadata")
58+
59+
entry.metadata = {
60+
"_truncated": True,
61+
"_original_size": len(raw.encode()),
62+
}
63+
64+
def _process_metadata_or_error(
65+
self, metadata_or_error: dict[str, Any] | Exception | None
66+
) -> dict[str, Any]:
67+
"""
68+
Normalise the metadata_or_error parameter used by error() and critical().
69+
Exceptions are serialized to a structured 'exception' key.
70+
"""
71+
if metadata_or_error is None:
72+
return {}
73+
if isinstance(metadata_or_error, dict):
74+
return metadata_or_error
75+
return {"exception": serialize_exception(metadata_or_error)}
76+
77+
def _is_logging_disabled(self) -> bool:
78+
if self._closed or self.options.local_mode is True:
79+
return True
80+
if self.options.local_mode == "if_unset_api_key" and not self.options.api_key:
81+
return True
82+
return False
83+
84+
def _pin_trace_id_to_entry(self, entry: LogEntry):
85+
self._set_trace_and_span_id_from_context(entry)
86+
87+
# Merge the current scope (tags, user, breadcrumbs, session, trace ctx).
88+
# Runs before trace-id injection so the scope's trace context wins
89+
# over auto-generation.
90+
get_current_scope().apply_to_entry(entry)
91+
92+
# Inject trace ID (last resort: generation or client instance variable)
93+
if entry.trace_id is None:
94+
if self.options.auto_trace_id:
95+
entry.trace_id = generate_trace_id()
96+
elif self._trace_id is not None:
97+
entry.trace_id = self._trace_id
98+
99+
def _set_trace_and_span_id_from_context(self, entry: LogEntry) -> None:
100+
# Active-span trace context (resolution order per spec 005 §4:
101+
# explicit -> active span -> scope -> client context/generation).
102+
if entry.trace_id is not None:
103+
return
104+
105+
active_trace, active_span = active_trace_context()
106+
if active_trace is None:
107+
return
108+
109+
entry.trace_id = active_trace
110+
if entry.span_id is None:
111+
entry.span_id = active_span

logtide_sdk/async_client.py

Lines changed: 9 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -17,28 +17,25 @@
1717
"Install it with: pip install logtide-sdk[async]"
1818
)
1919

20+
from logtide_sdk._base_client import BaseClient
21+
from logtide_sdk._retry import classify_failure
22+
from logtide_sdk._version import SDK_NAME, VERSION
2023
from logtide_sdk.circuit_breaker import CircuitBreaker
21-
from logtide_sdk.client import _process_value, serialize_exception
2224
from logtide_sdk.enums import CircuitState, LogLevel
2325
from logtide_sdk.exceptions import CircuitBreakerOpenError
2426
from logtide_sdk.json_encoder import logtide_json_dumps
25-
from logtide_sdk._retry import classify_failure
26-
from logtide_sdk._version import SDK_NAME, VERSION
27-
from logtide_sdk.scope import get_current_scope
28-
from logtide_sdk.tracecontext import active_trace_context, generate_trace_id
2927
from logtide_sdk.models import (
3028
AggregatedStatsOptions,
3129
AggregatedStatsResponse,
3230
ClientMetrics,
3331
ClientOptions,
3432
LogEntry,
3533
LogsResponse,
36-
PayloadLimitsOptions,
3734
QueryOptions,
3835
)
3936

4037

41-
class AsyncLogTideClient:
38+
class AsyncLogTideClient(BaseClient):
4239
"""
4340
Async LogTide SDK Client.
4441
@@ -65,9 +62,9 @@ def __init__(self, options: ClientOptions) -> None:
6562
Args:
6663
options: Client configuration options (same as LogTideClient)
6764
"""
68-
self.options = options
65+
super().__init__(options=options)
66+
6967
self._buffer: list[LogEntry] = []
70-
self._trace_id: str | None = None
7168
self._buffer_lock: asyncio.Lock | None = None # created lazily in first async call
7269
self._metrics_lock = ThreadingLock()
7370
self._metrics = ClientMetrics()
@@ -76,7 +73,6 @@ def __init__(self, options: ClientOptions) -> None:
7673
reset_timeout_ms=options.circuit_breaker_reset_ms,
7774
)
7875
self._latency_window: list[float] = []
79-
self._payload_limits = options.payload_limits or PayloadLimitsOptions()
8076
self._session: aiohttp.ClientSession | None = None
8177
self._flush_task: Any | None = None # asyncio.Task[None]
8278
self._closed = False
@@ -130,18 +126,6 @@ async def close(self) -> None:
130126
if self.options.debug:
131127
print("[LogTide] Async client closed")
132128

133-
# -----------------------------------------------------------------------
134-
# Trace ID helpers
135-
# -----------------------------------------------------------------------
136-
137-
def set_trace_id(self, trace_id: str | None) -> None:
138-
"""Set trace ID for subsequent logs."""
139-
self._trace_id = trace_id
140-
141-
def get_trace_id(self) -> str | None:
142-
"""Return the current trace ID."""
143-
return self._trace_id
144-
145129
# -----------------------------------------------------------------------
146130
# Logging methods
147131
# -----------------------------------------------------------------------
@@ -153,31 +137,10 @@ async def log(self, entry: LogEntry) -> None:
153137
Args:
154138
entry: Pre-built log entry
155139
"""
156-
if self._closed:
140+
if self._is_logging_disabled():
157141
return
158142

159-
if entry.metadata is None:
160-
entry.metadata = {}
161-
162-
# Active-span trace context (resolution order per spec 005 §4:
163-
# explicit -> active span -> scope -> client context/generation).
164-
if entry.trace_id is None:
165-
active_trace, active_span = active_trace_context()
166-
if active_trace is not None:
167-
entry.trace_id = active_trace
168-
if entry.span_id is None:
169-
entry.span_id = active_span
170-
171-
# Merge the current scope (tags, user, breadcrumbs, session, trace ctx).
172-
# Runs before trace-id injection so the scope's trace context wins
173-
# over auto-generation.
174-
get_current_scope().apply_to_entry(entry)
175-
176-
if entry.trace_id is None:
177-
if self.options.auto_trace_id:
178-
entry.trace_id = generate_trace_id()
179-
elif self._trace_id is not None:
180-
entry.trace_id = self._trace_id
143+
self._pin_trace_id_to_entry(entry)
181144

182145
if self.options.global_metadata:
183146
entry.metadata = {**self.options.global_metadata, **entry.metadata}
@@ -507,12 +470,6 @@ def _get_session(self) -> aiohttp.ClientSession:
507470
self._session = aiohttp.ClientSession()
508471
return self._session
509472

510-
def _get_headers(self) -> dict[str, str]:
511-
return {
512-
"X-API-Key": self.options.api_key,
513-
"Content-Type": "application/json",
514-
}
515-
516473
async def _flush_loop(self) -> None:
517474
"""Background coroutine: flush on a fixed interval until closed."""
518475
interval = self.options.flush_interval / 1000.0
@@ -602,29 +559,7 @@ async def _send_logs(self, logs: list[LogEntry]) -> None:
602559
) as response:
603560
response.raise_for_status()
604561

605-
def _process_metadata_or_error(
606-
self, metadata_or_error: dict[str, Any] | Exception | None
607-
) -> dict[str, Any]:
608-
if metadata_or_error is None:
609-
return {}
610-
if isinstance(metadata_or_error, dict):
611-
return metadata_or_error
612-
return {"exception": serialize_exception(metadata_or_error)}
613-
614-
# NOTE: this is twice. (both in async and regular clients, maybe need base class)
615-
def _apply_payload_limits(self, entry: LogEntry) -> None:
616-
"""Enforce payload limits on entry.metadata in-place."""
617-
if not entry.metadata:
618-
return
619-
lim = self._payload_limits
620-
entry.metadata = _process_value(entry.metadata, "root", lim)
621-
622-
raw = logtide_json_dumps(entry)
623-
if len(raw.encode()) > lim.max_log_size:
624-
if self.options.debug:
625-
print(f"[LogTide] Log entry too large ({len(raw)} bytes), truncating metadata")
626-
entry.metadata = {"_truncated": True, "_original_size": len(raw.encode())}
627-
562+
# TODO: refactor update latency code repeat
628563
def _update_latency(self, latency: float) -> None:
629564
with self._metrics_lock:
630565
self._latency_window.append(latency)

0 commit comments

Comments
 (0)