Skip to content

Commit 2cca70f

Browse files
committed
feat: add logger field to client configs for user defined sdk logging behavior
1 parent c12284f commit 2cca70f

File tree

6 files changed

+234
-39
lines changed

6 files changed

+234
-39
lines changed

src/amplitude_experiment/local/client.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import logging
21
from threading import Lock
32
from typing import Any, List, Dict, Set
43

@@ -47,10 +46,7 @@ def __init__(self, api_key: str, config: LocalEvaluationConfig = None):
4746
instance = Amplitude(config.assignment_config.api_key, config.assignment_config)
4847
self.assignment_service = AssignmentService(instance, AssignmentFilter(
4948
config.assignment_config.cache_capacity), config.assignment_config.send_evaluated_props)
50-
self.logger = logging.getLogger("Amplitude")
51-
self.logger.addHandler(logging.StreamHandler())
52-
if self.config.debug:
53-
self.logger.setLevel(logging.DEBUG)
49+
self.logger = self.config.logger
5450
self.__setup_connection_pool()
5551
self.lock = Lock()
5652
self.cohort_storage = InMemoryCohortStorage()
Lines changed: 50 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,38 @@
1+
import logging
2+
import sys
3+
14
from ..assignment import AssignmentConfig
2-
from ..cohort.cohort_sync_config import CohortSyncConfig, DEFAULT_COHORT_SYNC_URL, EU_COHORT_SYNC_URL
5+
from ..cohort.cohort_sync_config import (
6+
CohortSyncConfig,
7+
DEFAULT_COHORT_SYNC_URL,
8+
EU_COHORT_SYNC_URL,
9+
)
310
from ..server_zone import ServerZone
411

5-
DEFAULT_SERVER_URL = 'https://api.lab.amplitude.com'
6-
EU_SERVER_URL = 'https://flag.lab.eu.amplitude.com'
12+
DEFAULT_SERVER_URL = "https://api.lab.amplitude.com"
13+
EU_SERVER_URL = "https://flag.lab.eu.amplitude.com"
714

8-
DEFAULT_STREAM_URL = 'https://stream.lab.amplitude.com'
9-
EU_STREAM_SERVER_URL = 'https://stream.lab.eu.amplitude.com'
15+
DEFAULT_STREAM_URL = "https://stream.lab.amplitude.com"
16+
EU_STREAM_SERVER_URL = "https://stream.lab.eu.amplitude.com"
1017

1118

1219
class LocalEvaluationConfig:
1320
"""Experiment Local Client Configuration"""
1421

15-
def __init__(self, debug: bool = False,
16-
server_url: str = DEFAULT_SERVER_URL,
17-
server_zone: ServerZone = ServerZone.US,
18-
flag_config_polling_interval_millis: int = 30000,
19-
flag_config_poller_request_timeout_millis: int = 10000,
20-
stream_updates: bool = False,
21-
stream_server_url: str = DEFAULT_STREAM_URL,
22-
stream_flag_conn_timeout: int = 1500,
23-
assignment_config: AssignmentConfig = None,
24-
cohort_sync_config: CohortSyncConfig = None):
22+
def __init__(
23+
self,
24+
debug: bool = False,
25+
server_url: str = DEFAULT_SERVER_URL,
26+
server_zone: ServerZone = ServerZone.US,
27+
flag_config_polling_interval_millis: int = 30000,
28+
flag_config_poller_request_timeout_millis: int = 10000,
29+
stream_updates: bool = False,
30+
stream_server_url: str = DEFAULT_STREAM_URL,
31+
stream_flag_conn_timeout: int = 1500,
32+
assignment_config: AssignmentConfig = None,
33+
cohort_sync_config: CohortSyncConfig = None,
34+
logger: logging.Logger = None,
35+
):
2536
"""
2637
Initialize a config
2738
Parameters:
@@ -34,6 +45,8 @@ def __init__(self, debug: bool = False,
3445
fetching flag configurations.
3546
assignment_config (AssignmentConfig): The assignment configuration.
3647
cohort_sync_config (CohortSyncConfig): The cohort sync configuration.
48+
logger (logging.Logger): Optional logger instance. If provided, this logger will be used instead of
49+
creating a new one. The debug flag will still be applied to set the log level.
3750
3851
Returns:
3952
The config object
@@ -44,16 +57,35 @@ def __init__(self, debug: bool = False,
4457
self.cohort_sync_config = cohort_sync_config
4558
if server_url == DEFAULT_SERVER_URL and server_zone == ServerZone.EU:
4659
self.server_url = EU_SERVER_URL
47-
if (cohort_sync_config is not None and
48-
cohort_sync_config.cohort_server_url == DEFAULT_COHORT_SYNC_URL):
60+
if (
61+
cohort_sync_config is not None
62+
and cohort_sync_config.cohort_server_url == DEFAULT_COHORT_SYNC_URL
63+
):
4964
self.cohort_sync_config.cohort_server_url = EU_COHORT_SYNC_URL
5065

5166
self.stream_server_url = stream_server_url
5267
if stream_server_url == DEFAULT_SERVER_URL and server_zone == ServerZone.EU:
5368
self.stream_server_url = EU_STREAM_SERVER_URL
5469

5570
self.flag_config_polling_interval_millis = flag_config_polling_interval_millis
56-
self.flag_config_poller_request_timeout_millis = flag_config_poller_request_timeout_millis
71+
self.flag_config_poller_request_timeout_millis = (
72+
flag_config_poller_request_timeout_millis
73+
)
5774
self.stream_updates = stream_updates
5875
self.stream_flag_conn_timeout = stream_flag_conn_timeout
5976
self.assignment_config = assignment_config
77+
78+
# Set up logger: use provided logger or create default one
79+
if logger is None:
80+
self.logger = logging.getLogger("Amplitude")
81+
# Only add handler if logger doesn't already have one
82+
if not self.logger.handlers:
83+
handler = logging.StreamHandler(sys.stderr)
84+
self.logger.addHandler(handler)
85+
else:
86+
self.logger = logger
87+
88+
# Set log level: DEBUG if debug=True, otherwise WARNING
89+
# This applies to both provided loggers and the default logger
90+
log_level = logging.DEBUG if self.debug else logging.WARNING
91+
self.logger.setLevel(log_level)

src/amplitude_experiment/remote/client.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import json
2-
import logging
32
import threading
43
import time
54
from time import sleep
@@ -32,10 +31,7 @@ def __init__(self, api_key, config=None):
3231
raise ValueError("Experiment API key is empty")
3332
self.api_key = api_key
3433
self.config = config or RemoteEvaluationConfig()
35-
self.logger = logging.getLogger("Amplitude")
36-
self.logger.addHandler(logging.StreamHandler())
37-
if self.config.debug:
38-
self.logger.setLevel(logging.DEBUG)
34+
self.logger = self.config.logger
3935
self.__setup_connection_pool()
4036

4137
def fetch_v2(self, user: User):

src/amplitude_experiment/remote/config.py

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,27 @@
11
from ..server_zone import ServerZone
22

3-
DEFAULT_SERVER_URL = 'https://api.lab.amplitude.com'
4-
EU_SERVER_URL = 'https://api.lab.eu.amplitude.com'
3+
DEFAULT_SERVER_URL = "https://api.lab.amplitude.com"
4+
EU_SERVER_URL = "https://api.lab.eu.amplitude.com"
5+
import logging
6+
import sys
7+
58

69
class RemoteEvaluationConfig:
710
"""Experiment Remote Client Configuration"""
811

9-
def __init__(self, debug=False,
10-
server_url=DEFAULT_SERVER_URL,
11-
fetch_timeout_millis=10000,
12-
fetch_retries=0,
13-
fetch_retry_backoff_min_millis=500,
14-
fetch_retry_backoff_max_millis=10000,
15-
fetch_retry_backoff_scalar=1.5,
16-
fetch_retry_timeout_millis=10000,
17-
server_zone: ServerZone = ServerZone.US):
12+
def __init__(
13+
self,
14+
debug=False,
15+
server_url=DEFAULT_SERVER_URL,
16+
fetch_timeout_millis=10000,
17+
fetch_retries=0,
18+
fetch_retry_backoff_min_millis=500,
19+
fetch_retry_backoff_max_millis=10000,
20+
fetch_retry_backoff_scalar=1.5,
21+
fetch_retry_timeout_millis=10000,
22+
server_zone: ServerZone = ServerZone.US,
23+
logger=None,
24+
):
1825
"""
1926
Initialize a config
2027
Parameters:
@@ -30,6 +37,8 @@ def __init__(self, debug=False,
3037
fetch_retry_backoff_scalar (float): Scales the minimum backoff exponentially.
3138
fetch_retry_timeout_millis (int): The request timeout for retrying fetch requests.
3239
server_zone (str): Select the Amplitude data center to get flags and variants from, US or EU.
40+
logger (logging.Logger): Optional logger instance. If provided, this logger will be used instead of
41+
creating a new one. The debug flag will still be applied to set the log level.
3342
3443
Returns:
3544
The config object
@@ -46,3 +55,17 @@ def __init__(self, debug=False,
4655
if server_url == DEFAULT_SERVER_URL and server_zone == ServerZone.EU:
4756
self.server_url = EU_SERVER_URL
4857

58+
# Set up logger: use provided logger or create default one
59+
if logger is None:
60+
self.logger = logging.getLogger("Amplitude")
61+
# Only add handler if logger doesn't already have one
62+
if not self.logger.handlers:
63+
handler = logging.StreamHandler(sys.stderr)
64+
self.logger.addHandler(handler)
65+
else:
66+
self.logger = logger
67+
68+
# Set log level: DEBUG if debug=True, otherwise WARNING
69+
# This applies to both provided loggers and the default logger
70+
log_level = logging.DEBUG if self.debug else logging.WARNING
71+
self.logger.setLevel(log_level)

tests/local/config_test.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import io
2+
import logging
3+
import sys
4+
import unittest
5+
6+
from src.amplitude_experiment.local.config import LocalEvaluationConfig
7+
8+
9+
class LocalEvaluationConfigLoggerTestCase(unittest.TestCase):
10+
"""Tests for LocalEvaluationConfig logger configuration"""
11+
12+
def setUp(self):
13+
"""Clear existing handlers from the Amplitude logger before each test"""
14+
logger = logging.getLogger("Amplitude")
15+
logger.handlers.clear()
16+
logger.setLevel(logging.NOTSET)
17+
18+
def tearDown(self):
19+
"""Clean up handlers after each test"""
20+
logger = logging.getLogger("Amplitude")
21+
logger.handlers.clear()
22+
23+
def test_default_logger_has_warning_level(self):
24+
"""Test that default logger has WARNING level and stderr handler when debug=False"""
25+
config = LocalEvaluationConfig(debug=False)
26+
self.assertEqual(config.logger.level, logging.WARNING)
27+
self.assertEqual(len(config.logger.handlers), 1)
28+
handler = config.logger.handlers[0]
29+
self.assertIsInstance(handler, logging.StreamHandler)
30+
self.assertEqual(handler.stream, sys.stderr)
31+
32+
def test_default_logger_has_debug_level_when_debug_true(self):
33+
"""Test that default logger has DEBUG level when debug=True"""
34+
config = LocalEvaluationConfig(debug=True)
35+
self.assertEqual(config.logger.level, logging.DEBUG)
36+
37+
def test_custom_logger_is_used(self):
38+
"""Test that provided custom logger is used"""
39+
custom_logger = logging.getLogger("CustomLogger")
40+
config = LocalEvaluationConfig(logger=custom_logger)
41+
self.assertEqual(config.logger, custom_logger)
42+
self.assertEqual(config.logger.name, "CustomLogger")
43+
44+
def test_custom_logger_has_debug_level_when_debug_true(self):
45+
"""Test that custom logger has DEBUG level set when debug=True"""
46+
custom_logger = logging.getLogger("CustomLogger")
47+
config = LocalEvaluationConfig(debug=True, logger=custom_logger)
48+
self.assertEqual(config.logger.level, logging.DEBUG)
49+
50+
def test_custom_logger_has_warning_level_when_debug_false(self):
51+
"""Test that custom logger has WARNING level set when debug=False"""
52+
custom_logger = logging.getLogger("CustomLogger")
53+
config = LocalEvaluationConfig(debug=False, logger=custom_logger)
54+
self.assertEqual(config.logger.level, logging.WARNING)
55+
56+
def test_custom_logger_debug_flag_takes_precedence(self):
57+
"""Test that debug flag takes precedence over logger's existing level"""
58+
custom_logger = logging.getLogger("CustomLogger")
59+
custom_logger.setLevel(logging.ERROR)
60+
config = LocalEvaluationConfig(debug=True, logger=custom_logger)
61+
# Debug flag should override to DEBUG
62+
self.assertEqual(config.logger.level, logging.DEBUG)
63+
64+
def test_default_logger_only_one_handler_added(self):
65+
"""Test that only one handler is added to default logger"""
66+
config1 = LocalEvaluationConfig()
67+
config2 = LocalEvaluationConfig()
68+
# Both should use the same logger instance (singleton)
69+
logger = logging.getLogger("Amplitude")
70+
# Should only have one handler even after creating multiple configs
71+
self.assertEqual(len(logger.handlers), 1)
72+
73+
if __name__ == '__main__':
74+
unittest.main()

tests/remote/config_test.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import io
2+
import logging
3+
import sys
4+
import unittest
5+
6+
from src.amplitude_experiment.remote.config import RemoteEvaluationConfig
7+
8+
9+
class RemoteEvaluationConfigLoggerTestCase(unittest.TestCase):
10+
"""Tests for RemoteEvaluationConfig logger configuration"""
11+
12+
def setUp(self):
13+
"""Clear existing handlers from the Amplitude logger before each test"""
14+
logger = logging.getLogger("Amplitude")
15+
logger.handlers.clear()
16+
logger.setLevel(logging.NOTSET)
17+
18+
def tearDown(self):
19+
"""Clean up handlers after each test"""
20+
logger = logging.getLogger("Amplitude")
21+
logger.handlers.clear()
22+
23+
def test_default_logger_has_warning_level(self):
24+
"""Test that default logger has WARNING level and stderr handler when debug=False"""
25+
config = RemoteEvaluationConfig(debug=False)
26+
self.assertEqual(config.logger.level, logging.WARNING)
27+
self.assertEqual(len(config.logger.handlers), 1)
28+
handler = config.logger.handlers[0]
29+
self.assertIsInstance(handler, logging.StreamHandler)
30+
self.assertEqual(handler.stream, sys.stderr)
31+
32+
def test_default_logger_has_debug_level_when_debug_true(self):
33+
"""Test that default logger has DEBUG level when debug=True"""
34+
config = RemoteEvaluationConfig(debug=True)
35+
self.assertEqual(config.logger.level, logging.DEBUG)
36+
37+
def test_custom_logger_is_used(self):
38+
"""Test that provided custom logger is used"""
39+
custom_logger = logging.getLogger("CustomLogger")
40+
config = RemoteEvaluationConfig(logger=custom_logger)
41+
self.assertEqual(config.logger, custom_logger)
42+
self.assertEqual(config.logger.name, "CustomLogger")
43+
44+
def test_custom_logger_has_debug_level_when_debug_true(self):
45+
"""Test that custom logger has DEBUG level set when debug=True"""
46+
custom_logger = logging.getLogger("CustomLogger")
47+
config = RemoteEvaluationConfig(debug=True, logger=custom_logger)
48+
self.assertEqual(config.logger.level, logging.DEBUG)
49+
50+
def test_custom_logger_has_warning_level_when_debug_false(self):
51+
"""Test that custom logger has WARNING level set when debug=False"""
52+
custom_logger = logging.getLogger("CustomLogger")
53+
config = RemoteEvaluationConfig(debug=False, logger=custom_logger)
54+
self.assertEqual(config.logger.level, logging.WARNING)
55+
56+
def test_custom_logger_debug_flag_takes_precedence(self):
57+
"""Test that debug flag takes precedence over logger's existing level"""
58+
custom_logger = logging.getLogger("CustomLogger")
59+
custom_logger.setLevel(logging.ERROR)
60+
config = RemoteEvaluationConfig(debug=True, logger=custom_logger)
61+
# Debug flag should override to DEBUG
62+
self.assertEqual(config.logger.level, logging.DEBUG)
63+
64+
def test_default_logger_only_one_handler_added(self):
65+
"""Test that only one handler is added to default logger"""
66+
config1 = RemoteEvaluationConfig()
67+
config2 = RemoteEvaluationConfig()
68+
# Both should use the same logger instance (singleton)
69+
logger = logging.getLogger("Amplitude")
70+
# Should only have one handler even after creating multiple configs
71+
self.assertEqual(len(logger.handlers), 1)
72+
73+
if __name__ == '__main__':
74+
unittest.main()

0 commit comments

Comments
 (0)