Skip to content

Commit 59edbd8

Browse files
authored
observability integration (#369)
- allow user to plug in observability client (statsd/opentelemetry) - logger is now a telemetry logger sitting on top of obsv client + output logger. - https://www.notion.so/statsig/SDK-Observability-5c48660fa03c432f9d15e18b3d5c3600
1 parent 336f450 commit 59edbd8

15 files changed

+407
-88
lines changed

statsig/globals.py

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
11
import os
22

3-
from .output_logger import OutputLogger, LogLevel
3+
from .statsig_options import StatsigOptions
4+
from .statsig_telemetry_logger import StatsigTelemetryLogger
45

56
STATSIG_BATCHING_INTERVAL_SECONDS = 60.0
67
STATSIG_LOGGING_INTERVAL_SECONDS = 1.0
78

8-
logger = OutputLogger('statsig.sdk')
9+
logger = StatsigTelemetryLogger()
910

1011
os.environ["GRPC_VERBOSITY"] = "NONE"
1112

1213

13-
def set_logger(output_logger):
14-
global logger
15-
logger = output_logger
16-
17-
18-
def set_log_level(log_level: LogLevel):
19-
logger.set_log_level(log_level)
14+
def init_logger(options: StatsigOptions):
15+
if options.custom_logger is not None:
16+
logger.set_logger(options.custom_logger)
17+
elif options.output_logger_level is not None:
18+
logger.set_log_level(options.output_logger_level)
19+
if options.observability_client is not None:
20+
logger.set_ob_client(options.observability_client)
21+
logger.init()

statsig/grpc_websocket_worker.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ def get_dcs(
206206
log_on_exception: Optional[bool] = False,
207207
init_timeout: Optional[int] = None,
208208
):
209-
self.context.dcs_api = self.proxy_config.proxy_address
209+
self.context.source_api = self.proxy_config.proxy_address
210210
self._diagnostics.add_marker(
211211
Marker().download_config_specs().network_request().start()
212212
)

statsig/grpc_worker.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ def get_dcs(self, on_complete: Callable, since_time: int = 0, log_on_exception:
3030
init_timeout: Optional[int] = None):
3131
request = ConfigSpecRequest(sdkKey=self.sdk_key, sinceTime=since_time)
3232
try:
33-
self.context.dcs_api = self.proxy_config.proxy_address
33+
self.context.source_api = self.proxy_config.proxy_address
3434
response = self.stub.getConfigSpec(request)
3535
on_complete(DataSource.NETWORK, response.spec, None)
3636
except Exception as e:

statsig/http_worker.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ def get_dcs(self, on_complete: Callable, since_time=0, log_on_exception=False, i
5151
url=f"{self.__api_for_download_config_specs}download_config_specs/{self.__sdk_key}.json?sinceTime={since_time}",
5252
headers=None, init_timeout=init_timeout, log_on_exception=log_on_exception,
5353
tag="download_config_specs")
54-
self._context.dcs_api = self.__api_for_download_config_specs
54+
self._context.source_api = self.__api_for_download_config_specs
5555
if response is not None and self._is_success_code(response.status_code):
5656
on_complete(DataSource.NETWORK, response.json() or {}, None)
5757
return
@@ -62,7 +62,7 @@ def get_dcs_fallback(self, on_complete: Callable, since_time=0, log_on_exception
6262
url=f"{STATSIG_CDN}download_config_specs/{self.__sdk_key}.json?sinceTime={since_time}",
6363
headers=None, init_timeout=init_timeout, log_on_exception=log_on_exception,
6464
tag="download_config_specs")
65-
self._context.dcs_api = STATSIG_CDN
65+
self._context.source_api = STATSIG_CDN
6666
if response is not None and self._is_success_code(response.status_code):
6767
on_complete(DataSource.STATSIG_NETWORK, response.json() or {}, None)
6868
return

statsig/initialize_details.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from typing import Optional
2+
3+
4+
class InitializeDetails:
5+
duration: int
6+
source: str
7+
init_success: bool
8+
store_populated: bool
9+
error: Optional[Exception]
10+
timed_out: bool
11+
init_source_api: Optional[str]
12+
13+
def __init__(self, duration: int, source: str, init_success: bool, store_populated: bool,
14+
error: Optional[Exception], init_source_api: Optional[str] = None, timed_out: bool = False):
15+
self.duration = duration
16+
self.source = source
17+
self.init_success = init_success
18+
self.error = error
19+
self.store_populated = store_populated
20+
self.init_source_api = init_source_api
21+
self.timed_out = timed_out
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
from typing import Dict, Optional, Any
2+
3+
4+
# pylint: disable=unused-argument
5+
class ObservabilityClient:
6+
"""
7+
An interface for observability clients that allows users to plug in their
8+
own observability integration for metrics collection and monitoring.
9+
"""
10+
11+
def init(self, *args, **kwargs):
12+
"""
13+
Initializes the observability client with necessary configuration.
14+
15+
:param args: Positional arguments for initialization.
16+
:param kwargs: Keyword arguments for initialization.
17+
"""
18+
19+
def increment(self, metric_name: str, value: int = 1, tags: Optional[Dict[str, Any]] = None) -> None:
20+
"""
21+
Increment a counter metric.
22+
23+
:param metric_name: The name of the metric to increment.
24+
:param value: The value by which the counter should be incremented (default is 1).
25+
:param tags: Optional dictionary of tags for metric dimensions.
26+
"""
27+
28+
def gauge(self, metric_name: str, value: float, tags: Optional[Dict[str, Any]] = None) -> None:
29+
"""
30+
Set a gauge metric.
31+
32+
:param metric_name: The name of the metric to set.
33+
:param value: The value to set the gauge to.
34+
:param tags: Optional dictionary of tags for metric dimensions.
35+
"""
36+
37+
def distribution(self, metric_name: str, value: float, tags: Optional[Dict[str, Any]] = None) -> None:
38+
"""
39+
Record a distribution metric for tracking statistical data.
40+
41+
:param metric_name: The name of the metric to record.
42+
:param value: The recorded value for the distribution metric.
43+
:param tags: Optional dictionary of tags that represent dimensions to associate with the metric.
44+
"""
45+
46+
def should_enable_high_cardinality_for_this_tag(self, tag: str) -> bool:
47+
"""
48+
Determine if a high cardinality tag should be logged
49+
50+
:param tag: The tag to check for high cardinality enabled.
51+
"""
52+
return False
53+
54+
def shutdown(self) -> None:
55+
"""
56+
Shutdown the observability client.
57+
"""

statsig/spec_store.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ def __init__(
5858

5959
self._id_lists: Dict[str, dict] = {}
6060
self.unsupported_configs: Set[str] = set()
61+
self.context = context
6162

6263
self.spec_updater = SpecUpdater(
6364
network,
@@ -152,8 +153,13 @@ def _initialize_specs(self):
152153

153154
def _process_specs(self, specs_json, source: DataSource) -> Tuple[bool, bool]: # has update, parse success
154155
self._log_process("Processing specs...")
156+
prev_lcut = self.spec_updater.last_update_time
155157
if specs_json.get("has_updates", False) is False:
156-
globals.logger.debug("Received update: %s", "No Update")
158+
globals.logger.log_config_sync_update(self.spec_updater.initialized, False,
159+
self.spec_updater.last_update_time,
160+
prev_lcut,
161+
self.context.source,
162+
self.context.source_api)
157163
return False, True
158164
if not self.spec_updater.is_specs_json_valid(specs_json):
159165
self._log_process("Failed to process specs")
@@ -236,9 +242,14 @@ def parse_target_value_map_from_spec(spec, parsed):
236242
self._experiment_to_layer = new_experiment_to_layer
237243
self.spec_updater.last_update_time = specs_json.get("time", 0)
238244
self.init_source = source
245+
self.context.source = source
239246
self._default_environment = specs_json.get("default_environment", None)
240-
globals.logger.debug("Received update: %s", self.spec_updater.last_update_time)
241-
247+
if self.spec_updater.last_update_time > prev_lcut:
248+
globals.logger.log_config_sync_update(self.spec_updater.initialized, True,
249+
self.spec_updater.last_update_time,
250+
prev_lcut,
251+
self.context.source,
252+
self.context.source_api)
242253
flags = specs_json.get("sdk_flags", {})
243254
_SDK_Configs.set_flags(flags)
244255
configs = specs_json.get("sdk_configs", {})

statsig/spec_updater.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -384,23 +384,23 @@ def sync_config_spec():
384384
globals.logger.log_process(
385385
"Config Sync",
386386
f"Syncing config values with {strategy.value}"
387-
+ (f"[{self.context.dcs_api}]" if self.context.dcs_api else "")
387+
+ (f"[{self.context.source_api}]" if self.context.source_api else "")
388388
+ " successful"
389389
)
390390
break
391391
if i < len(self._config_sync_strategies) - 1:
392392
globals.logger.log_process(
393393
"Config Sync",
394394
f"Syncing config values failed with {strategy.value}"
395-
+ (f"[{self.context.dcs_api}]" if self.context.dcs_api else "")
395+
+ (f"[{self.context.source_api}]" if self.context.source_api else "")
396396
+ ", falling back to next available configured config sync method"
397397
)
398398

399399
else:
400400
globals.logger.log_process(
401401
"Config Sync",
402402
f"Syncing config values failed with {strategy.value}"
403-
+ (f"[{self.context.dcs_api}]" if self.context.dcs_api else "")
403+
+ (f"[{self.context.source_api}]" if self.context.source_api else "")
404404
+ f". No more strategies left. The next sync will be in {interval} seconds."
405405
)
406406

statsig/statsig.py

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
from typing import Optional
22

3-
from . import globals, FeatureGate
3+
from . import FeatureGate
44
from .client_initialize_formatter import ClientInitializeResponse
55
from .dynamic_config import DynamicConfig
6+
from .initialize_details import InitializeDetails
67
from .layer import Layer
78
from .statsig_event import StatsigEvent
89
from .statsig_options import StatsigOptions
9-
from .statsig_server import StatsigServer, InitializeDetails
10+
from .statsig_server import StatsigServer
1011
from .statsig_user import StatsigUser
1112
from .utils import HashingAlgorithm
1213

@@ -21,14 +22,6 @@ def initialize(secret_key: str, options: Optional[StatsigOptions] = None) -> Ini
2122
:param options: The StatsigOptions object used to configure the SDK
2223
:return: The initialization details
2324
"""
24-
if options is None:
25-
options = StatsigOptions()
26-
27-
if options.custom_logger is not None:
28-
globals.set_logger(options.custom_logger)
29-
elif options.output_logger_level is not None:
30-
globals.set_log_level(options.output_logger_level)
31-
3225
return __instance.initialize(secret_key, options)
3326

3427

statsig/statsig_context.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ class InitContext:
1010
error: Optional[Exception]
1111
source: DataSource
1212
store_populated: bool
13-
dcs_api: Optional[str]
13+
source_api: Optional[str]
1414
timed_out: bool
1515

1616
def __init__(self):
@@ -19,5 +19,5 @@ def __init__(self):
1919
self.error = None
2020
self.source = DataSource.UNINITIALIZED
2121
self.store_populated = False
22-
self.dcs_api = None
22+
self.source_api = None
2323
self.timed_out = False

0 commit comments

Comments
 (0)