tracely-sdk 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.
tracely/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """TRACELY SDK - Lightweight observability for Python web frameworks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.0"
6
+
7
+ from tracely.sdk import init, shutdown
8
+ from tracely.tracing import span
9
+ from tracely.logging_api import debug, info, warning, error
10
+
11
+ __all__ = ["init", "shutdown", "span", "debug", "info", "warning", "error", "__version__"]
tracely/capture.py ADDED
@@ -0,0 +1,210 @@
1
+ """Request/response data capture utilities.
2
+
3
+ Provides body processing (truncation at 64KB, binary content replacement),
4
+ URL construction, header sanitization, smart data redaction (FR8, FR11),
5
+ and span attribute attachment for full request/response data capture (FR6, FR7).
6
+
7
+ All public functions are fail-silent — exceptions are caught and logged
8
+ at DEBUG level to satisfy the SDK's zero-crash guarantee.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import logging
15
+ from typing import Any
16
+
17
+ from tracely.redaction import (
18
+ get_extra_fields,
19
+ redact_body,
20
+ redact_headers,
21
+ redact_patterns,
22
+ )
23
+
24
+ logger = logging.getLogger("tracely")
25
+
26
+ # 64KB body size limit per AC3
27
+ MAX_BODY_SIZE: int = 64 * 1024
28
+
29
+ # Binary content type prefixes — bodies with these types are replaced
30
+ # with a placeholder rather than captured verbatim.
31
+ _BINARY_PREFIXES: tuple[str, ...] = (
32
+ "image/",
33
+ "video/",
34
+ "audio/",
35
+ "application/octet-stream",
36
+ "application/zip",
37
+ "application/gzip",
38
+ "application/pdf",
39
+ "application/x-tar",
40
+ "application/x-bzip2",
41
+ "application/x-7z-compressed",
42
+ "application/vnd.",
43
+ "font/",
44
+ )
45
+
46
+
47
+ def is_binary_content_type(content_type: str) -> bool:
48
+ """Return True if the content type represents binary data.
49
+
50
+ Matching is case-insensitive and ignores parameters (charset, etc.).
51
+ """
52
+ if not content_type:
53
+ return False
54
+ # Normalize: lowercase, strip parameters for prefix check
55
+ ct_lower = content_type.lower().split(";")[0].strip()
56
+ return ct_lower.startswith(_BINARY_PREFIXES)
57
+
58
+
59
+ def process_body(
60
+ body: bytes | str | None,
61
+ content_type: str,
62
+ ) -> tuple[str, dict[str, str]]:
63
+ """Process a request or response body for span storage.
64
+
65
+ Returns:
66
+ A tuple of (processed_body_str, metadata_dict).
67
+ metadata_dict may contain:
68
+ - http.body.original_length: original byte length (if truncated or binary)
69
+ - http.body.truncated: "true" (if truncated)
70
+
71
+ Rules (per AC3/AC4):
72
+ - Bodies > 64KB are truncated with a ``[truncated]`` marker.
73
+ - Binary content types are replaced with ``[binary: <ct>, <size> bytes]``.
74
+ - Empty/None bodies return empty string with no metadata.
75
+ """
76
+ if body is None:
77
+ return "", {}
78
+
79
+ if isinstance(body, str):
80
+ raw = body.encode("utf-8", errors="replace")
81
+ else:
82
+ raw = body
83
+
84
+ if len(raw) == 0:
85
+ return "", {}
86
+
87
+ meta: dict[str, str] = {}
88
+
89
+ # Binary content type — replace with placeholder (AC4)
90
+ if is_binary_content_type(content_type or ""):
91
+ meta["body.original_length"] = str(len(raw))
92
+ return f"[binary: {content_type}, {len(raw)} bytes]", meta
93
+
94
+ # Truncation at 64KB (AC3)
95
+ if len(raw) > MAX_BODY_SIZE:
96
+ meta["body.original_length"] = str(len(raw))
97
+ meta["body.truncated"] = "true"
98
+ truncated = raw[:MAX_BODY_SIZE].decode("utf-8", errors="replace")
99
+ return truncated + "[truncated]", meta
100
+
101
+ return raw.decode("utf-8", errors="replace"), meta
102
+
103
+
104
+ def build_url(scheme: str, host: str, path: str, query_string: str) -> str:
105
+ """Construct a full URL from its components.
106
+
107
+ Args:
108
+ scheme: URL scheme (http, https).
109
+ host: Hostname, optionally with port (e.g. "example.com:8080").
110
+ path: Request path (e.g. "/api/users").
111
+ query_string: Raw query string without leading '?'.
112
+
113
+ Returns:
114
+ Full URL string.
115
+ """
116
+ if not path:
117
+ path = "/"
118
+ elif not path.startswith("/"):
119
+ path = "/" + path
120
+
121
+ url = f"{scheme}://{host}{path}"
122
+ if query_string:
123
+ url = f"{url}?{query_string}"
124
+ return url
125
+
126
+
127
+ def sanitize_headers(
128
+ headers: dict[str, str] | list[tuple[bytes, bytes]] | None,
129
+ ) -> str:
130
+ """Convert headers to a JSON string for span attribute storage.
131
+
132
+ Applies smart redaction (FR8) to sensitive header values before
133
+ serialization. The original headers object is never mutated.
134
+
135
+ Accepts:
136
+ - dict[str, str]: Already key-value pairs.
137
+ - list[tuple[bytes, bytes]]: ASGI-style raw header pairs.
138
+ - None: Returns empty JSON object.
139
+
140
+ Returns:
141
+ JSON string representation of headers with sensitive values redacted.
142
+ """
143
+ if headers is None:
144
+ return "{}"
145
+
146
+ # redact_headers returns a new dict — original data is never modified
147
+ redacted = redact_headers(headers)
148
+ if not redacted:
149
+ return "{}"
150
+ return json.dumps(redacted, default=str)
151
+
152
+
153
+ def capture_request_data(
154
+ span: Any,
155
+ *,
156
+ method: str,
157
+ url: str,
158
+ headers: dict[str, str] | list[tuple[bytes, bytes]] | None = None,
159
+ body: bytes | str | None = None,
160
+ content_type: str = "",
161
+ query_params: str = "",
162
+ ) -> None:
163
+ """Attach request data as span attributes (FR6).
164
+
165
+ Fail-silent — never raises.
166
+ """
167
+ try:
168
+ span.set_attribute("http.method", method)
169
+ span.set_attribute("http.url", url)
170
+ span.set_attribute("http.request.headers", sanitize_headers(headers))
171
+
172
+ processed_body, meta = process_body(body, content_type)
173
+ # Apply smart data redaction (FR8) — field-name + pattern-based
174
+ redacted_body = redact_body(processed_body, extra_fields=get_extra_fields())
175
+ redacted_body = redact_patterns(redacted_body)
176
+ span.set_attribute("http.request.body", redacted_body)
177
+ for key, value in meta.items():
178
+ span.set_attribute(f"http.request.{key}", value)
179
+
180
+ if query_params:
181
+ span.set_attribute("http.request.query", query_params)
182
+ except Exception:
183
+ logger.debug("Error capturing request data", exc_info=True)
184
+
185
+
186
+ def capture_response_data(
187
+ span: Any,
188
+ *,
189
+ status_code: int,
190
+ headers: dict[str, str] | list[tuple[bytes, bytes]] | None = None,
191
+ body: bytes | str | None = None,
192
+ content_type: str = "",
193
+ ) -> None:
194
+ """Attach response data as span attributes (FR7).
195
+
196
+ Fail-silent — never raises.
197
+ """
198
+ try:
199
+ span.set_attribute("http.status_code", str(status_code))
200
+ span.set_attribute("http.response.headers", sanitize_headers(headers))
201
+
202
+ processed_body, meta = process_body(body, content_type or "")
203
+ # Apply smart data redaction (FR8) — field-name + pattern-based
204
+ redacted_body = redact_body(processed_body, extra_fields=get_extra_fields())
205
+ redacted_body = redact_patterns(redacted_body)
206
+ span.set_attribute("http.response.body", redacted_body)
207
+ for key, value in meta.items():
208
+ span.set_attribute(f"http.response.{key}", value)
209
+ except Exception:
210
+ logger.debug("Error capturing response data", exc_info=True)
tracely/config.py ADDED
@@ -0,0 +1,39 @@
1
+ """Configuration management for the TRACELY SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass, field
7
+
8
+ DEFAULT_ENDPOINT = "https://i.tracely.sh"
9
+
10
+
11
+ @dataclass
12
+ class TracelyConfig:
13
+ """SDK configuration. Reads from env vars or accepts explicit values."""
14
+
15
+ api_key: str | None = None
16
+ environment: str | None = None
17
+ endpoint: str = DEFAULT_ENDPOINT
18
+ service_name: str | None = None
19
+ service_version: str | None = None
20
+ redact_fields: frozenset[str] = field(default_factory=frozenset)
21
+
22
+ @classmethod
23
+ def from_env(cls) -> TracelyConfig:
24
+ """Create config from environment variables."""
25
+ raw_fields = os.environ.get("TRACELY_REDACT_FIELDS", "")
26
+ redact_fields = frozenset(
27
+ f.strip().lower() for f in raw_fields.split(",") if f.strip()
28
+ )
29
+ return cls(
30
+ api_key=os.environ.get("TRACELY_API_KEY"),
31
+ environment=os.environ.get("ENVIRONMENT"),
32
+ endpoint=os.environ.get("TRACELY_ENDPOINT", DEFAULT_ENDPOINT),
33
+ redact_fields=redact_fields,
34
+ )
35
+
36
+ @property
37
+ def enabled(self) -> bool:
38
+ """SDK is enabled only when an API key is present."""
39
+ return self.api_key is not None
tracely/context.py ADDED
@@ -0,0 +1,55 @@
1
+ """Context propagation for active span tracking.
2
+
3
+ Uses Python's contextvars for async-safe, per-task span tracking.
4
+ Each async task or thread gets its own active span, preventing
5
+ cross-request contamination.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from contextlib import contextmanager
11
+ from contextvars import ContextVar, Token
12
+ from typing import TYPE_CHECKING, Generator
13
+
14
+ if TYPE_CHECKING:
15
+ from tracely.span import Span
16
+
17
+ _current_span: ContextVar[Span | None] = ContextVar("_current_span", default=None)
18
+
19
+
20
+ def get_current_span() -> Span | None:
21
+ """Return the currently active span, or None if no span is active."""
22
+ return _current_span.get()
23
+
24
+
25
+ def set_current_span(span: Span | None, token: Token[Span | None] | None = None) -> Token[Span | None]:
26
+ """Set the currently active span.
27
+
28
+ Args:
29
+ span: The span to set as active, or None to clear.
30
+ token: Optional token from a previous set_current_span call
31
+ to restore the prior value.
32
+
33
+ Returns:
34
+ A token that can be used to restore the previous value.
35
+ """
36
+ if token is not None:
37
+ _current_span.reset(token)
38
+ return token
39
+ return _current_span.set(span)
40
+
41
+
42
+ @contextmanager
43
+ def _span_context(span: Span) -> Generator[Span, None, None]:
44
+ """Context manager that sets span as active and restores the previous span on exit.
45
+
46
+ Usage:
47
+ with _span_context(my_span):
48
+ # my_span is the active span here
49
+ # previous span (or None) is restored
50
+ """
51
+ token = _current_span.set(span)
52
+ try:
53
+ yield span
54
+ finally:
55
+ _current_span.reset(token)
tracely/detection.py ADDED
@@ -0,0 +1,49 @@
1
+ """Framework auto-detection for the TRACELY SDK.
2
+
3
+ Detects installed web frameworks (FastAPI, Django, Flask) and database
4
+ libraries (SQLAlchemy, pymongo) using importlib.util.find_spec() — a
5
+ non-importing check that avoids side effects.
6
+
7
+ Detection priority: FastAPI > Django > Flask.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ from dataclasses import dataclass, field
14
+ from importlib.util import find_spec
15
+
16
+ logger = logging.getLogger("tracely")
17
+
18
+ DETECTION_ORDER = ("fastapi", "django", "flask")
19
+
20
+
21
+ @dataclass
22
+ class FrameworkInfo:
23
+ """Detected framework and companion libraries."""
24
+
25
+ name: str
26
+ has_sqlalchemy: bool = False
27
+ has_pymongo: bool = False
28
+
29
+
30
+ def detect_framework() -> FrameworkInfo | None:
31
+ """Detect installed web framework and database libraries.
32
+
33
+ Returns FrameworkInfo for the highest-priority detected framework,
34
+ or None if no supported framework is found.
35
+
36
+ Never raises — all exceptions are caught silently (FR10).
37
+ """
38
+ try:
39
+ for framework_name in DETECTION_ORDER:
40
+ if find_spec(framework_name) is not None:
41
+ return FrameworkInfo(
42
+ name=framework_name,
43
+ has_sqlalchemy=find_spec("sqlalchemy") is not None,
44
+ has_pymongo=find_spec("pymongo") is not None,
45
+ )
46
+ return None
47
+ except Exception:
48
+ logger.debug("TRACELY framework detection failed", exc_info=True)
49
+ return None
tracely/exporter.py ADDED
@@ -0,0 +1,120 @@
1
+ """Batch span exporter with background flush loop (FR9, AC3).
2
+
3
+ Drains SpanBuffer on a timer interval or batch-size threshold,
4
+ serializes to OTLP protobuf, and sends via HttpTransport.
5
+
6
+ Uses a daemon thread with its own event loop so it works in both
7
+ sync (Flask/Django) and async (FastAPI) host applications.
8
+
9
+ All operations are fail-silent — never crashes the host application.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import logging
16
+ import threading
17
+ from typing import TYPE_CHECKING
18
+
19
+ from tracely.otlp import serialize_spans
20
+
21
+ if TYPE_CHECKING:
22
+ from tracely.transport import HttpTransport, SpanBuffer
23
+
24
+ logger = logging.getLogger("tracely")
25
+
26
+ DEFAULT_FLUSH_INTERVAL = 1.0 # seconds (AC3)
27
+
28
+
29
+ class BatchSpanExporter:
30
+ """Background exporter that batch-sends buffered spans via OTLP/HTTP.
31
+
32
+ Runs in a daemon thread with its own asyncio event loop so that
33
+ SDK init/shutdown can be called from any context (sync or async).
34
+
35
+ Args:
36
+ buffer: SpanBuffer to drain spans from.
37
+ transport: HttpTransport to send OTLP protobuf bytes.
38
+ flush_interval: Seconds between flush cycles (default 1.0 per AC3).
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ buffer: SpanBuffer,
44
+ transport: HttpTransport,
45
+ flush_interval: float = DEFAULT_FLUSH_INTERVAL,
46
+ ) -> None:
47
+ self._buffer = buffer
48
+ self._transport = transport
49
+ self._flush_interval = flush_interval
50
+ self._loop: asyncio.AbstractEventLoop | None = None
51
+ self._thread: threading.Thread | None = None
52
+ self._stop_event = threading.Event()
53
+ self._notify_event = threading.Event()
54
+
55
+ def start(self) -> None:
56
+ """Start the background export thread."""
57
+ self._stop_event.clear()
58
+ self._notify_event.clear()
59
+ self._loop = asyncio.new_event_loop()
60
+ self._thread = threading.Thread(
61
+ target=self._run, daemon=True, name="tracely-exporter",
62
+ )
63
+ self._thread.start()
64
+
65
+ def stop(self) -> None:
66
+ """Stop the background thread and drain remaining spans.
67
+
68
+ Blocks until the thread exits (up to 5 seconds).
69
+ The thread performs a final flush before exiting.
70
+ """
71
+ self._stop_event.set()
72
+ self._notify_event.set() # Wake up if sleeping
73
+ if self._thread is not None and self._thread.is_alive():
74
+ self._thread.join(timeout=5.0)
75
+
76
+ def notify(self) -> None:
77
+ """Wake the export loop (e.g. when batch threshold is reached)."""
78
+ self._notify_event.set()
79
+
80
+ def _run(self) -> None:
81
+ """Background loop running in daemon thread."""
82
+ asyncio.set_event_loop(self._loop)
83
+ try:
84
+ while not self._stop_event.is_set():
85
+ # Wait for interval or notification, whichever comes first
86
+ self._notify_event.wait(timeout=self._flush_interval)
87
+ self._notify_event.clear()
88
+
89
+ if self._stop_event.is_set():
90
+ break
91
+
92
+ self._loop.run_until_complete(self._flush())
93
+
94
+ # Final drain before thread exits
95
+ self._loop.run_until_complete(self._flush())
96
+ except Exception:
97
+ logger.debug("TRACELY: Error in exporter thread", exc_info=True)
98
+ finally:
99
+ self._loop.close()
100
+
101
+ async def _flush(self) -> None:
102
+ """Drain buffer, serialize to OTLP protobuf, send via transport."""
103
+ spans = self._buffer.flush()
104
+ if not spans:
105
+ return
106
+
107
+ try:
108
+ payload = serialize_spans(spans)
109
+ except Exception:
110
+ logger.debug("TRACELY: Error serializing spans to OTLP", exc_info=True)
111
+ return
112
+
113
+ try:
114
+ success = await self._transport.send(payload)
115
+ if not success:
116
+ logger.debug(
117
+ "TRACELY: Batch export failed for %d spans", len(spans),
118
+ )
119
+ except Exception:
120
+ logger.debug("TRACELY: Unexpected error in batch export", exc_info=True)
@@ -0,0 +1,47 @@
1
+ """Instrumentor registry and factory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+
7
+ from tracely.detection import FrameworkInfo
8
+ from tracely.instrumentation.base import BaseInstrumentor
9
+
10
+ logger = logging.getLogger("tracely")
11
+
12
+ # Lazy-loaded instrumentor classes keyed by framework name.
13
+ _REGISTRY: dict[str, str] = {
14
+ "fastapi": "tracely.instrumentation.fastapi_inst.FastAPIInstrumentor",
15
+ "django": "tracely.instrumentation.django_inst.DjangoInstrumentor",
16
+ "flask": "tracely.instrumentation.flask_inst.FlaskInstrumentor",
17
+ }
18
+
19
+
20
+ def get_instrumentor(framework_info: FrameworkInfo) -> BaseInstrumentor | None:
21
+ """Return the instrumentor for the given framework, or None if unknown.
22
+
23
+ Uses lazy import to avoid pulling in framework-specific code until needed.
24
+ Never raises — returns None on any failure.
25
+ """
26
+ qualname = _REGISTRY.get(framework_info.name)
27
+ if qualname is None:
28
+ logger.debug("No instrumentor registered for %s", framework_info.name)
29
+ return None
30
+
31
+ try:
32
+ module_path, class_name = qualname.rsplit(".", 1)
33
+ import importlib
34
+
35
+ module = importlib.import_module(module_path)
36
+ cls = getattr(module, class_name)
37
+ return cls(framework_info)
38
+ except Exception:
39
+ logger.debug(
40
+ "Failed to load instrumentor for %s",
41
+ framework_info.name,
42
+ exc_info=True,
43
+ )
44
+ return None
45
+
46
+
47
+ __all__ = ["BaseInstrumentor", "get_instrumentor"]
@@ -0,0 +1,30 @@
1
+ """Base instrumentor interface for framework auto-instrumentation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+
7
+ from tracely.detection import FrameworkInfo
8
+
9
+
10
+ class BaseInstrumentor(ABC):
11
+ """Abstract base class for framework-specific instrumentors.
12
+
13
+ Subclasses implement activate() to install hooks/middleware and
14
+ deactivate() to remove them on shutdown.
15
+ """
16
+
17
+ def __init__(self, framework_info: FrameworkInfo) -> None:
18
+ self._framework_info = framework_info
19
+
20
+ @property
21
+ def framework_info(self) -> FrameworkInfo:
22
+ return self._framework_info
23
+
24
+ @abstractmethod
25
+ def activate(self) -> None:
26
+ """Install instrumentation hooks for the detected framework."""
27
+
28
+ @abstractmethod
29
+ def deactivate(self) -> None:
30
+ """Remove instrumentation hooks (cleanup on shutdown)."""