Skip to content

Commit f75c590

Browse files
authored
Merge branch 'main' into add-exposure-service
2 parents 38f2048 + 69aa85f commit f75c590

File tree

14 files changed

+262
-30
lines changed

14 files changed

+262
-30
lines changed

.github/workflows/docs.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ jobs:
1010
uses: actions/checkout@v2
1111

1212
- name: Set up Python
13-
uses: actions/setup-python@v5
13+
uses: actions/setup-python@v6
1414

1515
- name: Set up pdoc
1616
run: pip install pdoc3

.github/workflows/publish-to-pypi.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ jobs:
3232
fetch-depth: 0
3333

3434
- name: Set up Python 3.8
35-
uses: actions/setup-python@v5
35+
uses: actions/setup-python@v6
3636
with:
3737
python-version: 3.8
3838

.github/workflows/publish-to-test-pypi.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ jobs:
2222
- uses: actions/checkout@v3
2323

2424
- name: Set up Python 3.8
25-
uses: actions/setup-python@v5
25+
uses: actions/setup-python@v6
2626
with:
2727
python-version: 3.8
2828

.github/workflows/test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ jobs:
1414
uses: actions/checkout@v3
1515

1616
- name: Set up Python ${{ matrix.python-version }}
17-
uses: actions/setup-python@v5
17+
uses: actions/setup-python@v6
1818
with:
1919
python-version: ${{ matrix.python-version }}
2020
cache: 'pip'

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
<!--next-version-placeholder-->
44

5+
## v1.9.0 (2025-11-20)
6+
7+
### Feature
8+
9+
* Allow configs to take in user provided logger instances for client logging behavior ([#65](https://github.com/amplitude/experiment-python-server/issues/65)) ([`688dc70`](https://github.com/amplitude/experiment-python-server/commit/688dc70a32763bcac91bde1fc8abcbb31b2d26de))
10+
511
## v1.8.3 (2025-11-10)
612

713
### Fix

src/amplitude_experiment/local/client.py

Lines changed: 1 addition & 6 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

@@ -49,17 +48,13 @@ def __init__(self, api_key: str, config: LocalEvaluationConfig = None):
4948
instance = Amplitude(config.assignment_config.api_key, config.assignment_config)
5049
self.assignment_service = AssignmentService(instance, AssignmentFilter(
5150
config.assignment_config.cache_capacity), config.assignment_config.send_evaluated_props)
52-
5351
# Exposure service is always instantiated, using deployment key if no api key provided
5452
self.exposure_service = None
5553
if config and config.exposure_config:
5654
exposure_config = config.exposure_config
5755
exposure_instance = Amplitude(exposure_config.api_key, exposure_config)
5856
self.exposure_service = ExposureService(exposure_instance, ExposureFilter(exposure_config.cache_capacity))
59-
self.logger = logging.getLogger("Amplitude")
60-
self.logger.addHandler(logging.StreamHandler())
61-
if self.config.debug:
62-
self.logger.setLevel(logging.DEBUG)
57+
self.logger = self.config.logger
6358
self.__setup_connection_pool()
6459
self.lock = Lock()
6560
self.cohort_storage = InMemoryCohortStorage()

src/amplitude_experiment/local/config.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import logging
2+
import sys
3+
14
from ..assignment import AssignmentConfig
25
from ..exposure import ExposureConfig
36
from ..cohort.cohort_sync_config import CohortSyncConfig, DEFAULT_COHORT_SYNC_URL, EU_COHORT_SYNC_URL
@@ -23,7 +26,8 @@ def __init__(self, debug: bool = False,
2326
stream_flag_conn_timeout: int = 1500,
2427
assignment_config: AssignmentConfig = None,
2528
exposure_config: ExposureConfig = None,
26-
cohort_sync_config: CohortSyncConfig = None):
29+
cohort_sync_config: CohortSyncConfig = None,
30+
logger: logging.Logger = None):
2731
"""
2832
Initialize a config
2933
Parameters:
@@ -37,6 +41,8 @@ def __init__(self, debug: bool = False,
3741
assignment_config (AssignmentConfig): The assignment configuration. @deprecated use exposure_config instead.
3842
exposure_config (ExposureConfig): The exposure configuration.
3943
cohort_sync_config (CohortSyncConfig): The cohort sync configuration.
44+
logger (logging.Logger): Optional logger instance. If provided, this logger will be used instead of
45+
creating a new one. The debug flag only applies when no logger is provided.
4046
4147
Returns:
4248
The config object
@@ -61,3 +67,16 @@ def __init__(self, debug: bool = False,
6167
self.stream_flag_conn_timeout = stream_flag_conn_timeout
6268
self.assignment_config = assignment_config
6369
self.exposure_config = exposure_config
70+
# Set up logger: use provided logger or create default one
71+
if logger is None:
72+
self.logger = logging.getLogger("Amplitude")
73+
# Only add handler if logger doesn't already have one
74+
if not self.logger.handlers:
75+
handler = logging.StreamHandler(sys.stderr)
76+
self.logger.addHandler(handler)
77+
# Set log level: DEBUG if debug=True, otherwise WARNING
78+
# Only apply debug flag to default logger, not user-provided loggers
79+
log_level = logging.DEBUG if self.debug else logging.WARNING
80+
self.logger.setLevel(log_level)
81+
else:
82+
self.logger = logger

src/amplitude_experiment/remote/client.py

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import json
2-
import logging
32
import threading
43
import time
54
from time import sleep
65
from typing import Any, Dict
76

87
from .config import RemoteEvaluationConfig
8+
from .fetch_options import FetchOptions
99
from ..connection_pool import HTTPConnectionPool
1010
from ..exception import FetchException
1111
from ..user import User
@@ -32,26 +32,24 @@ def __init__(self, api_key, config=None):
3232
raise ValueError("Experiment API key is empty")
3333
self.api_key = api_key
3434
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)
35+
self.logger = self.config.logger
3936
self.__setup_connection_pool()
4037

41-
def fetch_v2(self, user: User):
38+
def fetch_v2(self, user: User, fetch_options: FetchOptions = None):
4239
"""
4340
Fetch all variants for a user synchronously. This method will automatically retry if configured, and throw if
4441
all retries fail. This function differs from fetch as it will return a default variant object if the flag
4542
was evaluated but the user was not assigned (i.e. off).
4643
4744
Parameters:
4845
user (User): The Experiment User to fetch variants for.
46+
fetch_options (FetchOptions): The Fetch Options
4947
5048
Returns:
5149
Variants Dictionary.
5250
"""
5351
try:
54-
return self.__fetch_internal(user)
52+
return self.__fetch_internal(user, fetch_options)
5553
except Exception as e:
5654
self.logger.error(f"[Experiment] Failed to fetch variants: {e}")
5755
raise e
@@ -67,17 +65,18 @@ def fetch_async_v2(self, user: User, callback=None):
6765
thread.start()
6866

6967
@deprecated("Use fetch_v2")
70-
def fetch(self, user: User):
68+
def fetch(self, user: User, fetch_options: FetchOptions = None):
7169
"""
7270
Fetch all variants for a user synchronous. This method will automatically retry if configured.
7371
Parameters:
7472
user (User): The Experiment User
73+
fetch_options (FetchOptions): The Fetch Options
7574
7675
Returns:
7776
Variants Dictionary.
7877
"""
7978
try:
80-
variants = self.fetch_v2(user)
79+
variants = self.fetch_v2(user, fetch_options)
8180
return self.__filter_default_variants(variants)
8281
except Exception:
8382
return {}
@@ -107,16 +106,16 @@ def __fetch_async_internal(self, user, callback):
107106
callback(user, {}, e)
108107
return {}
109108

110-
def __fetch_internal(self, user):
109+
def __fetch_internal(self, user, fetch_options: FetchOptions = None):
111110
self.logger.debug(f"[Experiment] Fetching variants for user: {user}")
112111
try:
113-
return self.__do_fetch(user)
112+
return self.__do_fetch(user, fetch_options)
114113
except Exception as e:
115114
self.logger.error(f"[Experiment] Fetch failed: {e}")
116115
if self.__should_retry_fetch(e):
117-
return self.__retry_fetch(user)
116+
return self.__retry_fetch(user, fetch_options)
118117

119-
def __retry_fetch(self, user):
118+
def __retry_fetch(self, user, fetch_options: FetchOptions = None):
120119
if self.config.fetch_retries == 0:
121120
return {}
122121
self.logger.debug("[Experiment] Retrying fetch")
@@ -125,21 +124,26 @@ def __retry_fetch(self, user):
125124
for i in range(self.config.fetch_retries):
126125
sleep(delay_millis / 1000.0)
127126
try:
128-
return self.__do_fetch(user)
127+
return self.__do_fetch(user, fetch_options)
129128
except Exception as e:
130129
self.logger.error(f"[Experiment] Retry failed: {e}")
131130
err = e
132131
delay_millis = min(delay_millis * self.config.fetch_retry_backoff_scalar,
133132
self.config.fetch_retry_backoff_max_millis)
134133
raise err
135134

136-
def __do_fetch(self, user):
135+
def __do_fetch(self, user, fetch_options: FetchOptions = None):
137136
start = time.time()
138137
user_context = self.__add_context(user)
139138
headers = {
140139
'Authorization': f"Api-Key {self.api_key}",
141140
'Content-Type': 'application/json;charset=utf-8'
142141
}
142+
if fetch_options and fetch_options.tracksAssignment is not None:
143+
headers['X-Amp-Exp-Track'] = "track" if fetch_options.tracksAssignment else "no-track"
144+
if fetch_options and fetch_options.tracksExposure is not None:
145+
headers['X-Amp-Exp-Exposure-Track'] = "track" if fetch_options.tracksExposure else "no-track"
146+
143147
conn = self._connection_pool.acquire()
144148
body = user_context.to_json().encode('utf8')
145149
if len(body) > 8000:

src/amplitude_experiment/remote/config.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
DEFAULT_SERVER_URL = 'https://api.lab.amplitude.com'
44
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"""
@@ -14,7 +17,8 @@ def __init__(self, debug=False,
1417
fetch_retry_backoff_max_millis=10000,
1518
fetch_retry_backoff_scalar=1.5,
1619
fetch_retry_timeout_millis=10000,
17-
server_zone: ServerZone = ServerZone.US):
20+
server_zone: ServerZone = ServerZone.US,
21+
logger=None):
1822
"""
1923
Initialize a config
2024
Parameters:
@@ -30,6 +34,8 @@ def __init__(self, debug=False,
3034
fetch_retry_backoff_scalar (float): Scales the minimum backoff exponentially.
3135
fetch_retry_timeout_millis (int): The request timeout for retrying fetch requests.
3236
server_zone (str): Select the Amplitude data center to get flags and variants from, US or EU.
37+
logger (logging.Logger): Optional logger instance. If provided, this logger will be used instead of
38+
creating a new one. The debug flag only applies when no logger is provided.
3339
3440
Returns:
3541
The config object
@@ -46,3 +52,16 @@ def __init__(self, debug=False,
4652
if server_url == DEFAULT_SERVER_URL and server_zone == ServerZone.EU:
4753
self.server_url = EU_SERVER_URL
4854

55+
# Set up logger: use provided logger or create default one
56+
if logger is None:
57+
self.logger = logging.getLogger("Amplitude")
58+
# Only add handler if logger doesn't already have one
59+
if not self.logger.handlers:
60+
handler = logging.StreamHandler(sys.stderr)
61+
self.logger.addHandler(handler)
62+
# Set log level: DEBUG if debug=True, otherwise WARNING
63+
# Only apply debug flag to default logger, not user-provided loggers
64+
log_level = logging.DEBUG if self.debug else logging.WARNING
65+
self.logger.setLevel(log_level)
66+
else:
67+
self.logger = logger
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from typing import Optional
2+
class FetchOptions:
3+
def __init__(self, tracksAssignment: Optional[bool] = None, tracksExposure: Optional[bool] = None):
4+
"""
5+
Fetch Options
6+
Parameters:
7+
tracksAssignment (Optional[bool]): Whether to track the assignment. The default None uses the server's default behavior (track the assignment event).
8+
tracksExposure (Optional[bool]): Whether to track the exposure. The default None uses the server's default behavior (don't track the exposure event).
9+
"""
10+
self.tracksAssignment = tracksAssignment
11+
self.tracksExposure = tracksExposure
12+
13+
def __str__(self):
14+
return f"FetchOptions(tracksAssignment={self.tracksAssignment}, tracksExposure={self.tracksExposure})"

0 commit comments

Comments
 (0)