diff --git a/.github/workflows/pull-requests.yaml b/.github/workflows/pull-requests.yaml index 75a1265..19dfcc3 100644 --- a/.github/workflows/pull-requests.yaml +++ b/.github/workflows/pull-requests.yaml @@ -67,10 +67,80 @@ jobs: run: black --check confidence --exclude="telemetry_pb2.py|_version.py" - name: Run flake8 formatter check - run: flake8 confidence --exclude=telemetry_pb2.py,_version.py + run: flake8 confidence --exclude=telemetry_pb2.py,_version.py,telemetry.py - name: Run type linter check - run: mypy confidence --follow-imports=skip --exclude=telemetry_pb2.py + run: mypy confidence --follow-imports=skip --exclude telemetry_pb2.py --exclude telemetry.py - name: Run tests with pytest run: pytest + + test-installation-scenarios: + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + matrix: + python-version: ["3.11"] + installation-type: ["full", "minimal"] + + steps: + - name: Check out src from Git + uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Upgrade pip + run: pip install --upgrade pip + + - name: Install full dependencies (with protobuf) + if: matrix.installation-type == 'full' + run: | + pip install -e ".[dev]" + + - name: Install minimal dependencies (without protobuf) + if: matrix.installation-type == 'minimal' + run: | + pip install --no-deps -e . + pip install requests==2.32.4 openfeature-sdk==0.4.2 typing_extensions==4.9.0 httpx==0.27.2 + pip install pytest==7.4.2 pytest-mock==3.11.1 + + - name: Test telemetry functionality + run: | + python -c " + from confidence.telemetry import Telemetry, PROTOBUF_AVAILABLE, ProtoTraceId, ProtoStatus + print(f'Installation: ${{ matrix.installation-type }}') + print(f'Protobuf available: {PROTOBUF_AVAILABLE}') + + telemetry = Telemetry('test-version') + telemetry.add_trace(ProtoTraceId.PROTO_TRACE_ID_RESOLVE_LATENCY, 100, ProtoStatus.PROTO_STATUS_SUCCESS) + header = telemetry.get_monitoring_header() + + if '${{ matrix.installation-type }}' == 'full': + assert PROTOBUF_AVAILABLE == True, 'Protobuf should be available in full installation' + assert len(header) > 0, 'Header should not be empty in full installation' + print('✅ Full installation: Telemetry enabled') + else: + assert PROTOBUF_AVAILABLE == False, 'Protobuf should not be available in minimal installation' + assert header == '', 'Header should be empty in minimal installation' + print('✅ Minimal installation: Telemetry disabled') + " + + - name: Run core functionality tests + run: | + # Run a subset of tests to verify core functionality works in both scenarios + python -c " + from confidence.confidence import Confidence + from openfeature import api + from confidence.openfeature_provider import ConfidenceOpenFeatureProvider + + # Test basic SDK initialization (should work in both scenarios) + confidence = Confidence('fake-token', disable_telemetry=True) + provider = ConfidenceOpenFeatureProvider(confidence) + print('✅ Core SDK functionality works') + " diff --git a/.gitignore b/.gitignore index 2351f12..ee46fa0 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ dist/ build/ confidence/_version.py .env +.claude/ diff --git a/README.md b/README.md index 17844e6..60e6906 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,19 @@ the [OpenFeature reference documentation](https://openfeature.dev/docs/reference pip install spotify-confidence-sdk==2.0.2 ``` +This installs the full SDK including telemetry support and is the suggested . + +#### Minimal installation (without telemetry) +For environments where you cannot use protobuf, you can install without protobuf (which disables telemetry): + +```bash +pip install spotify-confidence-sdk==2.0.1 --no-deps +pip install requests==2.32.4 openfeature-sdk==0.4.2 typing_extensions==4.9.0 httpx==0.27.2 +``` + #### requirements.txt -```python +```txt +# Full installation (recommended) spotify-confidence-sdk==2.0.2 pip install -r requirements.txt @@ -117,7 +128,15 @@ confidence = Confidence("CLIENT_TOKEN", logger=quiet_logger) The SDK includes telemetry functionality that helps monitor SDK performance and usage. By default, telemetry is enabled and collects metrics (anonymously) such as resolve latency and request status. This data is used by the Confidence team to improve the product, and in certain cases it is also available to the SDK adopters. -You can disable telemetry by setting `disable_telemetry=True` when initializing the Confidence client: +### Telemetry behavior + +- **Default installation**: Telemetry is enabled automatically when protobuf dependencies are available +- **Minimal installation**: Telemetry is automatically disabled when protobuf is not installed (see [minimal installation](#minimal-installation-without-telemetry)) +- **Manual control**: You can explicitly disable telemetry even when dependencies are available + +### Disabling telemetry + +You can explicitly disable telemetry by setting `disable_telemetry=True` when initializing the Confidence client: ```python confidence = Confidence("CLIENT_TOKEN", diff --git a/confidence/telemetry.py b/confidence/telemetry.py index 0cffcc0..ef0b3c4 100644 --- a/confidence/telemetry.py +++ b/confidence/telemetry.py @@ -1,19 +1,60 @@ import base64 from queue import Queue -from typing import Optional +from typing import Optional, Union, Any from typing_extensions import TypeAlias +from enum import IntEnum -from confidence.telemetry_pb2 import ( - ProtoMonitoring, - ProtoLibraryTraces, - ProtoPlatform, -) +# Try to import protobuf components, fallback to mock types if unavailable +try: + from confidence.telemetry_pb2 import ( + ProtoMonitoring, + ProtoLibraryTraces, + ProtoPlatform, + ) -# Define type aliases for the protobuf classes -ProtoTrace: TypeAlias = ProtoLibraryTraces.ProtoTrace -ProtoLibrary: TypeAlias = ProtoLibraryTraces.ProtoLibrary -ProtoTraceId: TypeAlias = ProtoLibraryTraces.ProtoTraceId -ProtoStatus: TypeAlias = ProtoLibraryTraces.ProtoTrace.ProtoRequestTrace.ProtoStatus + # Define type aliases for the protobuf classes + ProtoTrace: TypeAlias = ProtoLibraryTraces.ProtoTrace + ProtoLibrary: TypeAlias = ProtoLibraryTraces.ProtoLibrary + ProtoTraceId: TypeAlias = ProtoLibraryTraces.ProtoTraceId + ProtoStatus: TypeAlias = ProtoLibraryTraces.ProtoTrace.ProtoRequestTrace.ProtoStatus + + PROTOBUF_AVAILABLE = True +except ImportError: + PROTOBUF_AVAILABLE = False + + # Fallback enum classes that match protobuf enum values + class ProtoLibrary(IntEnum): + PROTO_LIBRARY_UNSPECIFIED = 0 + PROTO_LIBRARY_CONFIDENCE = 1 + PROTO_LIBRARY_OPEN_FEATURE = 2 + PROTO_LIBRARY_REACT = 3 + + class ProtoTraceId(IntEnum): + PROTO_TRACE_ID_UNSPECIFIED = 0 + PROTO_TRACE_ID_RESOLVE_LATENCY = 1 + PROTO_TRACE_ID_STALE_FLAG = 2 + PROTO_TRACE_ID_FLAG_TYPE_MISMATCH = 3 + PROTO_TRACE_ID_WITH_CONTEXT = 4 + + class ProtoStatus(IntEnum): + PROTO_STATUS_UNSPECIFIED = 0 + PROTO_STATUS_SUCCESS = 1 + PROTO_STATUS_ERROR = 2 + PROTO_STATUS_TIMEOUT = 3 + PROTO_STATUS_CACHED = 4 + + class ProtoPlatform(IntEnum): + PROTO_PLATFORM_UNSPECIFIED = 0 + PROTO_PLATFORM_JS_WEB = 4 + PROTO_PLATFORM_JS_SERVER = 5 + PROTO_PLATFORM_PYTHON = 6 + PROTO_PLATFORM_GO = 7 + + # Mock trace class for type compatibility + class ProtoTrace: + def __init__(self): + self.id = None + self.request_trace = None class Telemetry: @@ -40,7 +81,7 @@ def __init__(self, version: str, disabled: bool = False) -> None: def add_trace( self, trace_id: ProtoTraceId, duration_ms: int, status: ProtoStatus ) -> None: - if self._disabled: + if self._disabled or not PROTOBUF_AVAILABLE: return trace = ProtoTrace() trace.id = trace_id @@ -51,7 +92,7 @@ def add_trace( self._traces_queue.put(trace) def get_monitoring_header(self) -> str: - if self._disabled: + if self._disabled or not PROTOBUF_AVAILABLE: return "" current_traces = [] while not self._traces_queue.empty(): diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index 31bfc64..b61b7c8 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -2,17 +2,28 @@ import base64 import time from unittest.mock import patch, MagicMock -from confidence.telemetry_pb2 import ProtoMonitoring, ProtoLibraryTraces, ProtoPlatform -from confidence.telemetry import Telemetry +from confidence.telemetry import Telemetry, PROTOBUF_AVAILABLE from confidence.confidence import Confidence, Region import requests -# Get the nested classes from ProtoLibraryTraces -ProtoTrace = ProtoLibraryTraces.ProtoTrace -ProtoRequestTrace = ProtoTrace.ProtoRequestTrace -ProtoStatus = ProtoRequestTrace.ProtoStatus -ProtoLibrary = ProtoLibraryTraces.ProtoLibrary -ProtoTraceId = ProtoLibraryTraces.ProtoTraceId +# Import protobuf types if available, otherwise use fallback types +if PROTOBUF_AVAILABLE: + from confidence.telemetry_pb2 import ProtoMonitoring, ProtoLibraryTraces, ProtoPlatform + # Get the nested classes from ProtoLibraryTraces + ProtoTrace = ProtoLibraryTraces.ProtoTrace + ProtoRequestTrace = ProtoTrace.ProtoRequestTrace + ProtoStatus = ProtoRequestTrace.ProtoStatus + ProtoLibrary = ProtoLibraryTraces.ProtoLibrary + ProtoTraceId = ProtoLibraryTraces.ProtoTraceId +else: + from confidence.telemetry import ( + ProtoLibrary, ProtoTraceId, ProtoStatus, ProtoPlatform, ProtoTrace + ) + + +def requires_protobuf(test_func): + """Decorator to skip tests that require protobuf when it's not available""" + return unittest.skipUnless(PROTOBUF_AVAILABLE, "protobuf not available")(test_func) class TestTelemetry(unittest.TestCase): @@ -30,21 +41,26 @@ def test_add_trace(self): ) header = telemetry.get_monitoring_header() - monitoring = ProtoMonitoring() - monitoring.ParseFromString(base64.b64decode(header)) - self.assertEqual(monitoring.platform, ProtoPlatform.PROTO_PLATFORM_PYTHON) - self.assertEqual(len(monitoring.library_traces), 1) - - library_trace = monitoring.library_traces[0] - self.assertEqual(library_trace.library, ProtoLibrary.PROTO_LIBRARY_CONFIDENCE) - self.assertEqual(library_trace.library_version, "1.0.0") - - self.assertEqual(len(library_trace.traces), 1) - trace = library_trace.traces[0] - self.assertEqual(trace.id, ProtoTraceId.PROTO_TRACE_ID_RESOLVE_LATENCY) - self.assertEqual(trace.request_trace.millisecond_duration, 100) - self.assertEqual(trace.request_trace.status, ProtoStatus.PROTO_STATUS_SUCCESS) + if PROTOBUF_AVAILABLE: + monitoring = ProtoMonitoring() + monitoring.ParseFromString(base64.b64decode(header)) + + self.assertEqual(monitoring.platform, ProtoPlatform.PROTO_PLATFORM_PYTHON) + self.assertEqual(len(monitoring.library_traces), 1) + + library_trace = monitoring.library_traces[0] + self.assertEqual(library_trace.library, ProtoLibrary.PROTO_LIBRARY_CONFIDENCE) + self.assertEqual(library_trace.library_version, "1.0.0") + + self.assertEqual(len(library_trace.traces), 1) + trace = library_trace.traces[0] + self.assertEqual(trace.id, ProtoTraceId.PROTO_TRACE_ID_RESOLVE_LATENCY) + self.assertEqual(trace.request_trace.millisecond_duration, 100) + self.assertEqual(trace.request_trace.status, ProtoStatus.PROTO_STATUS_SUCCESS) + else: + # When protobuf is not available, telemetry should return empty header + self.assertEqual(header, "") def test_traces_are_consumed(self): telemetry = Telemetry("1.0.0")