Skip to content
Open
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: 1 addition & 0 deletions .python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.10
13 changes: 7 additions & 6 deletions logtide_sdk/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,13 @@
"Install it with: pip install logtide-sdk[async]"
)

from logtide_sdk._retry import classify_failure
from logtide_sdk._version import SDK_NAME, VERSION
from logtide_sdk.circuit_breaker import CircuitBreaker
from logtide_sdk.client import _process_value, serialize_exception
from logtide_sdk.client import serialize_exception
from logtide_sdk.enums import CircuitState, LogLevel
from logtide_sdk.exceptions import CircuitBreakerOpenError
from logtide_sdk.json_encoder import logtide_json_dumps
from logtide_sdk._retry import classify_failure
from logtide_sdk._version import SDK_NAME, VERSION
from logtide_sdk.scope import get_current_scope
from logtide_sdk.tracecontext import active_trace_context, generate_trace_id
from logtide_sdk.models import (
AggregatedStatsOptions,
AggregatedStatsResponse,
Expand All @@ -36,6 +34,9 @@
PayloadLimitsOptions,
QueryOptions,
)
from logtide_sdk.payload_limits import apply_payload_limits
from logtide_sdk.scope import get_current_scope
from logtide_sdk.tracecontext import active_trace_context, generate_trace_id


class AsyncLogTideClient:
Expand Down Expand Up @@ -617,7 +618,7 @@ def _apply_payload_limits(self, entry: LogEntry) -> None:
if not entry.metadata:
return
lim = self._payload_limits
entry.metadata = _process_value(entry.metadata, "root", lim)
entry.metadata = apply_payload_limits(entry.metadata, "root", lim)

raw = logtide_json_dumps(entry)
if len(raw.encode()) > lim.max_log_size:
Expand Down
58 changes: 15 additions & 43 deletions logtide_sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import dataclasses
import json
import random
import re
import time
import traceback
from collections.abc import Callable, Iterator
Expand All @@ -14,14 +13,12 @@

import requests

from logtide_sdk._retry import classify_failure
from logtide_sdk._version import SDK_NAME, VERSION
from logtide_sdk.circuit_breaker import CircuitBreaker
from logtide_sdk.enums import CircuitState, LogLevel
from logtide_sdk.exceptions import CircuitBreakerOpenError
from logtide_sdk.json_encoder import logtide_json_dumps
from logtide_sdk._retry import classify_failure
from logtide_sdk._version import SDK_NAME, VERSION
from logtide_sdk.scope import get_current_scope
from logtide_sdk.tracecontext import active_trace_context, generate_trace_id
from logtide_sdk.models import (
AggregatedStatsOptions,
AggregatedStatsResponse,
Expand All @@ -32,20 +29,14 @@
PayloadLimitsOptions,
QueryOptions,
)
from logtide_sdk.payload_limits import apply_payload_limits
from logtide_sdk.scope import get_current_scope
from logtide_sdk.tracecontext import active_trace_context, generate_trace_id

# ---------------------------------------------------------------------------
# Module-level helpers (importable by async_client and middleware)
# ---------------------------------------------------------------------------

_BASE64_RE = re.compile(r"^[A-Za-z0-9+/=]{100,}$")


def _looks_like_base64(s: str) -> bool:
"""Return True if the string looks like base64-encoded or data-URI data."""
if s.startswith("data:"):
return True
return bool(_BASE64_RE.match(s.replace("\n", "").replace("\r", "")))


def serialize_exception(exc: BaseException) -> dict[str, Any]:
"""
Expand Down Expand Up @@ -82,31 +73,6 @@ def serialize_exception(exc: BaseException) -> dict[str, Any]:
return result


def _process_value(value: Any, path: str, lim: PayloadLimitsOptions) -> Any:
"""Recursively apply payload limits to a metadata value."""
if value is None:
return

field_name = path.split(".")[-1]
if field_name in lim.exclude_fields:
return "[EXCLUDED]"

if isinstance(value, str):
if len(value) >= 100 and _looks_like_base64(value):
return "[BASE64 DATA REMOVED]"
if len(value) > lim.max_field_size:
return value[: lim.max_field_size] + lim.truncation_marker
return value

if isinstance(value, dict):
return {k: _process_value(v, f"{path}.{k}", lim) for k, v in value.items()}

if isinstance(value, list):
return [_process_value(v, f"{path}[{i}]", lim) for i, v in enumerate(value)]

return value


# ---------------------------------------------------------------------------
# Main client
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -236,7 +202,6 @@ def _resolve_call(
resolved_payload = message_or_payload if message_or_payload is not None else payload
return self.options.service, service_or_message, resolved_payload


def log(self, entry: LogEntry) -> None:
"""
Log a pre-built entry. Applies trace ID, global metadata, and
Expand All @@ -245,9 +210,16 @@ def log(self, entry: LogEntry) -> None:
Args:
entry: Log entry to send
"""
if self._closed:
# TODO: this method is long (lines of code), need to split it up

if self._closed or self.options.local_mode is True:
return

if self.options.local_mode == "if_unset_api_key" and not self.options.api_key:
return

# if self.options

# Coerce None to {} so unpacking never raises TypeError
if entry.metadata is None:
entry.metadata = {}
Expand Down Expand Up @@ -298,9 +270,9 @@ def log(self, entry: LogEntry) -> None:
if self.options.sample_rate < 1.0 and random.random() > self.options.sample_rate:
return

# Apply payload limits before buffering
self._apply_payload_limits(entry)

# TODO: move than logic from there
should_flush = False
with self._buffer_lock:
if len(self._buffer) >= self.options.max_buffer_size:
Expand Down Expand Up @@ -829,7 +801,7 @@ def _apply_payload_limits(self, entry: LogEntry) -> None:
return

lim = self._payload_limits
entry.metadata = _process_value(entry.metadata, "root", lim)
entry.metadata = apply_payload_limits(entry.metadata, "root", lim)

# Enforce total entry size
raw = logtide_json_dumps(entry)
Expand Down
26 changes: 17 additions & 9 deletions logtide_sdk/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
from typing import Any, Literal

from logtide_sdk.dsn import parse_dsn
from logtide_sdk.enums import LogLevel
Expand Down Expand Up @@ -64,8 +64,9 @@ class ClientOptions:
Provide either ``dsn`` or ``api_url`` + ``api_key``.
"""

api_url: str = ""
api_key: str = ""
api_url: str = "https://api.logtide.dev"
api_key: str | None = None
local_mode: bool | Literal["if_unset_api_key"] = False
batch_size: int = 100
flush_interval: int = 5000
max_buffer_size: int = 10000
Expand All @@ -86,17 +87,24 @@ class ClientOptions:
def __post_init__(self) -> None:
if not 0.0 <= self.sample_rate <= 1.0:
raise ValueError("sample_rate must be between 0.0 and 1.0")

if self.dsn:
parts = parse_dsn(self.dsn)
if not self.api_url:
self.api_url = parts.api_url
if not self.api_key:
self.api_key = parts.api_key
if not self.api_url or not self.api_key:
self.api_url = parts.api_url
self.api_key = parts.api_key

if (
self.local_mode
and self.local_mode is not True
and self.local_mode != "if_unset_api_key"
):
raise ValueError(
"Either dsn or api_url + api_key must be provided to ClientOptions"
"Local mode cannot be positive value other then True or 'if_unset_api_key'"
)

if self.local_mode is False and (not self.api_url or not self.api_key):
raise ValueError("Either dsn or api_url + api_key must be provided to ClientOptions")


@dataclass
class QueryOptions:
Expand Down
40 changes: 40 additions & 0 deletions logtide_sdk/payload_limits.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import re
from typing import Any

from logtide_sdk.models import PayloadLimitsOptions


def apply_payload_limits(value: Any, path: str, lim: PayloadLimitsOptions) -> Any:
"""Recursively apply payload limits and hide base64 to a metadata value."""

if value is None:
return

field_name = path.split(".")[-1]
if field_name in lim.exclude_fields:
return "[EXCLUDED]"

if isinstance(value, str):
if len(value) >= 100 and _looks_like_base64(value):
return "[BASE64 DATA REMOVED]"
if len(value) > lim.max_field_size:
return value[: lim.max_field_size] + lim.truncation_marker
return value

if isinstance(value, dict):
return {k: apply_payload_limits(v, f"{path}.{k}", lim) for k, v in value.items()}

if isinstance(value, list):
return [apply_payload_limits(v, f"{path}[{i}]", lim) for i, v in enumerate(value)]

return value


_BASE64_RE = re.compile(r"^[A-Za-z0-9+/=]{100,}$")


def _looks_like_base64(s: str) -> bool:
"""Return True if the string looks like base64-encoded or data-URI data."""
if s.startswith("data:"):
return True
return bool(_BASE64_RE.match(s.replace("\n", "").replace("\r", "")))
14 changes: 12 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,10 @@ Issues = "https://github.com/logtide-dev/logtide-python/issues"

[project.optional-dependencies]
async = ["aiohttp>=3.9.0"]
otel = ["opentelemetry-sdk>=1.20.0", "opentelemetry-exporter-otlp-proto-http>=1.20.0"]
otel = [
"opentelemetry-sdk>=1.20.0",
"opentelemetry-exporter-otlp-proto-http>=1.20.0",
]
starlette = ["starlette>=0.27.0"]
flask = ["flask>=2.0.0"]
django = ["django>=3.2.0"]
Expand All @@ -67,11 +70,11 @@ tests = [
"fastapi>=0.100.0",
"starlette>=0.27.0",
"build",
"pytest>=8.3.5",
"pytest-asyncio>=0.24.0",
"pytest-cov>=5.0.0",
"freezegun>=1.5.5",
"pytest-mock>=3.14.1",
"pytest>=9.0.3",
]
all = [{ include-group = "tests" }]

Expand Down Expand Up @@ -119,3 +122,10 @@ python_classes = ["Test*"]
python_functions = ["test_*"]
asyncio_mode = "strict"
asyncio_default_fixture_loop_scope = "function"

[tool.pyright]
reportUnnecessaryTypeIgnoreComment = true
reportMissingTypeStubs = false
include = ["logtide_sdk", "tests"]
venvPath = "."
venv = ".venv"
67 changes: 57 additions & 10 deletions tests/test_client.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Basic tests for LogTide SDK."""

from dataclasses import dataclass
from typing import Literal
from unittest.mock import MagicMock

import pytest
Expand All @@ -16,17 +17,8 @@
from logtide_sdk.models import LogEntry


@pytest.fixture
def client() -> LogTideClient:
return LogTideClient(
ClientOptions(
api_url="http://localhost:8080",
api_key="test_key",
)
)


def test_client_initialization():
# leave it here as part of the test
client = LogTideClient(
ClientOptions(
api_url="http://localhost:8080",
Expand All @@ -39,6 +31,19 @@ def test_client_initialization():
client.close()


@pytest.fixture
def options() -> ClientOptions:
return ClientOptions(
api_url="http://localhost:8080",
api_key="test_key",
)


@pytest.fixture
def client(options: ClientOptions) -> LogTideClient:
return LogTideClient(options)


def test_logging_methods(client: LogTideClient):
# Test all log levels
client.debug("test-service", "Debug message")
Expand Down Expand Up @@ -231,3 +236,45 @@ def test_flush_with_unjsonable_payload_and_no_trace_id(
data=json_string,
timeout=30,
)


@pytest.mark.parametrize("local_mode", ["if_unset_api_key", True])
def test_log_does_nothing_in_local_mode(
mocker: MockerFixture, local_mode: Literal["if_unset_api_key"] | bool
) -> None:
client = LogTideClient(ClientOptions(api_url="https://someapiurl.com", local_mode=local_mode))
apply_payload_limits_mock = mocker.patch.object(client, "_apply_payload_limits")
flush_mock = mocker.patch.object(client, "flush")

client.log(
LogEntry(
service="randomService-name",
level=LogLevel.INFO,
message="Some random message that shouldn't be sent",
)
)

apply_payload_limits_mock.assert_not_called()
flush_mock.assert_not_called()

assert len(client._buffer) == 0


def test_log_does_smth_if_api_key_and_local_if_unset_api_key(mocker: MockerFixture) -> None:
client = LogTideClient(
ClientOptions(
api_url="https://someapiurl.com",
api_key="abc_some_api_key",
local_mode="if_unset_api_key",
)
)

client.log(
LogEntry(
service="randomService-name",
level=LogLevel.INFO,
message="Some random message that shouldn't be sent",
)
)

assert len(client._buffer) != 0
Loading
Loading