Skip to content

Commit d2bf632

Browse files
feat: Propagate environment ID to evaluation hooks
Co-Authored-By: rlamb@launchdarkly.com <4955475+kinyoklion@users.noreply.github.com>
1 parent bc0d06e commit d2bf632

18 files changed

Lines changed: 368 additions & 16 deletions

ldclient/client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -709,7 +709,7 @@ def __evaluate_with_hooks(self, key: str, context: Context, default_value: Any,
709709

710710
hooks = self.__hooks.copy()
711711

712-
series_context = EvaluationSeriesContext(key=key, context=context, default_value=default_value, method=method)
712+
series_context = EvaluationSeriesContext(key=key, context=context, default_value=default_value, method=method, environment_id=self._data_system.environment_id)
713713
hook_data = self.__execute_before_evaluation(hooks, series_context)
714714
evaluation_result = block()
715715
self.__execute_after_evaluation(hooks, series_context, hook_data, evaluation_result.evaluation_detail)

ldclient/hook.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from abc import ABC, abstractmethod
22
from dataclasses import dataclass
3-
from typing import Any
3+
from typing import Any, Optional
44

55
from ldclient.context import Context
66
from ldclient.evaluation import EvaluationDetail
@@ -17,6 +17,7 @@ class EvaluationSeriesContext:
1717
context: Context #: The context used during evaluation.
1818
default_value: Any #: The default value provided to the evaluation method
1919
method: str #: The string version of the method which triggered the evaluation series.
20+
environment_id: Optional[str] = None #: The environment ID the SDK is connected to, if available.
2021

2122

2223
@dataclass

ldclient/impl/datasource/datasource_common.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@
55
# currently excluded from documentation - see docs/README.md
66

77
from collections import namedtuple
8-
from typing import Optional
8+
from typing import Mapping, Optional, Protocol, runtime_checkable
99

10+
from ldclient.impl.util import _LD_ENVID_HEADER
1011
from ldclient.interfaces import DataSourceUpdateSink, FeatureStore
1112
from ldclient.versioned_data_kind import FEATURES, SEGMENTS
1213

@@ -34,6 +35,32 @@ def sink_or_store(sink: Optional[DataSourceUpdateSink], store: FeatureStore):
3435
return sink
3536

3637

38+
@runtime_checkable
39+
class EnvironmentIdSink(Protocol):
40+
"""
41+
Implemented by data source update sinks which can record the environment ID
42+
reported by LaunchDarkly. This is separate from
43+
:class:`ldclient.interfaces.DataSourceUpdateSink` so that externally
44+
implemented sinks remain compatible.
45+
"""
46+
47+
def set_environment_id(self, environment_id: str) -> None:
48+
...
49+
50+
51+
def record_environment_id(sink, headers: Optional[Mapping[str, str]]):
52+
"""
53+
Records the environment ID from a set of LaunchDarkly response headers, if
54+
both the headers and the sink provide one.
55+
"""
56+
if headers is None or not isinstance(sink, EnvironmentIdSink):
57+
return
58+
59+
environment_id = headers.get(_LD_ENVID_HEADER)
60+
if isinstance(environment_id, str) and environment_id != '':
61+
sink.set_environment_id(environment_id)
62+
63+
3764
def parse_path(path: str):
3865
for kind in [FEATURES, SEGMENTS]:
3966
if path.startswith(kind.stream_api_path):

ldclient/impl/datasource/feature_requester.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import json
66
from collections import namedtuple
7+
from typing import Mapping, Optional, Tuple
78
from urllib import parse
89

910
import urllib3
@@ -27,6 +28,10 @@ def __init__(self, config):
2728
self._poll_uri += '?%s' % parse.urlencode({'filter': config.payload_filter_key})
2829

2930
def get_all_data(self):
31+
(data, _) = self.get_all_data_with_headers()
32+
return data
33+
34+
def get_all_data_with_headers(self) -> Tuple[dict, Optional[Mapping[str, str]]]:
3035
uri = self._poll_uri
3136
hdrs = _headers(self._config)
3237
cache_entry = self._cache.get(uri)
@@ -47,4 +52,4 @@ def get_all_data(self):
4752
self._cache[uri] = CacheEntry(data=data, etag=etag)
4853
log.debug("%s response status:[%d] From cache? [%s] ETag:[%s]", uri, r.status, from_cache, etag)
4954

50-
return {FEATURES: data['flags'], SEGMENTS: data['segments']}
55+
return ({FEATURES: data['flags'], SEGMENTS: data['segments']}, r.headers)

ldclient/impl/datasource/polling.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,13 @@
66

77
import time
88
from threading import Event
9-
from typing import Optional
9+
from typing import Any, Mapping, Optional, Protocol, Tuple, runtime_checkable
1010

1111
from ldclient.config import Config
12-
from ldclient.impl.datasource.datasource_common import sink_or_store
12+
from ldclient.impl.datasource.datasource_common import (
13+
record_environment_id,
14+
sink_or_store
15+
)
1316
from ldclient.impl.repeating_task import RepeatingTask
1417
from ldclient.impl.util import (
1518
UnsuccessfulResponseException,
@@ -28,6 +31,12 @@
2831
)
2932

3033

34+
@runtime_checkable
35+
class _FeatureRequesterWithHeaders(Protocol):
36+
def get_all_data_with_headers(self) -> Tuple[Any, Optional[Mapping[str, str]]]:
37+
...
38+
39+
3140
class PollingUpdateProcessor(UpdateProcessor):
3241
def __init__(self, config: Config, requester: FeatureRequester, store: FeatureStore, ready: Event):
3342
self._config = config
@@ -58,7 +67,8 @@ def __stop_with_error_info(self, error: Optional[DataSourceErrorInfo]):
5867

5968
def _poll(self):
6069
try:
61-
all_data = self._requester.get_all_data()
70+
(all_data, headers) = self._get_all_data_with_headers()
71+
record_environment_id(self._data_source_update_sink, headers)
6272
sink_or_store(self._data_source_update_sink, self._store).init(all_data)
6373
if not self._ready.is_set() and self._store.initialized:
6474
log.info("PollingUpdateProcessor initialized ok")
@@ -84,3 +94,13 @@ def _poll(self):
8494

8595
if self._data_source_update_sink is not None:
8696
self._data_source_update_sink.update_status(DataSourceState.INTERRUPTED, DataSourceErrorInfo(DataSourceErrorKind.UNKNOWN, 0, time.time(), str(e)))
97+
98+
def _get_all_data_with_headers(self) -> Tuple[Any, Optional[Mapping[str, str]]]:
99+
"""
100+
Externally provided feature requesters are not required to surface
101+
response headers, so fall back to the data-only method.
102+
"""
103+
if isinstance(self._requester, _FeatureRequesterWithHeaders):
104+
return self._requester.get_all_data_with_headers()
105+
106+
return (self._requester.get_all_data(), None)

ldclient/impl/datasource/status.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,22 @@ def __init__(self, store: FeatureStore, status_listeners: Listeners, flag_change
2626

2727
self.__lock = ReadWriteLock()
2828
self.__status = DataSourceStatus(DataSourceState.INITIALIZING, time.time(), None)
29+
self.__environment_id: Optional[str] = None
2930

3031
@property
3132
def status(self) -> DataSourceStatus:
3233
with self.__lock.read():
3334
return self.__status
3435

36+
@property
37+
def environment_id(self) -> Optional[str]:
38+
with self.__lock.read():
39+
return self.__environment_id
40+
41+
def set_environment_id(self, environment_id: str) -> None:
42+
with self.__lock.write():
43+
self.__environment_id = environment_id
44+
3545
def init(self, all_data: Mapping[VersionedDataKind, Mapping[str, dict]]):
3646
old_data = None
3747

ldclient/impl/datasource/streaming.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from urllib import parse
66

77
from ld_eventsource import SSEClient
8-
from ld_eventsource.actions import Event, Fault
8+
from ld_eventsource.actions import Event, Fault, Start
99
from ld_eventsource.config import (
1010
ConnectStrategy,
1111
ErrorStrategy,
@@ -16,6 +16,7 @@
1616
from ldclient.impl.datasource.datasource_common import (
1717
STREAM_ALL_PATH,
1818
parse_path,
19+
record_environment_id,
1920
sink_or_store
2021
)
2122
from ldclient.impl.http import HTTPFactory, _http_factory
@@ -62,7 +63,9 @@ def run(self):
6263
self._sse = self._create_sse_client()
6364
self._connection_attempt_start_time = time.time()
6465
for action in self._sse.all:
65-
if isinstance(action, Event):
66+
if isinstance(action, Start):
67+
record_environment_id(self._data_source_update_sink, action.headers)
68+
elif isinstance(action, Event):
6669
message_ok = False
6770
try:
6871
message_ok = self._process_message(sink_or_store(self._data_source_update_sink, self._store), action)
@@ -91,6 +94,8 @@ def run(self):
9194
log.info("StreamingUpdateProcessor initialized ok.")
9295
self._ready.set()
9396
elif isinstance(action, Fault):
97+
record_environment_id(self._data_source_update_sink, action.headers)
98+
9499
# If the SSE client detects the stream has closed, then it will emit a fault with no-error. We can
95100
# ignore this since we want the connection to continue.
96101
if action.error is None:

ldclient/impl/datasystem/__init__.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from abc import abstractmethod
77
from enum import Enum
88
from threading import Event
9-
from typing import Protocol, runtime_checkable
9+
from typing import Optional, Protocol, runtime_checkable
1010

1111
from ldclient.impl.listeners import Listeners
1212
from ldclient.interfaces import (
@@ -142,6 +142,18 @@ def store(self) -> ReadOnlyStore:
142142
"""
143143
raise NotImplementedError
144144

145+
@property
146+
@abstractmethod
147+
def environment_id(self) -> Optional[str]:
148+
"""
149+
Returns the environment ID reported by LaunchDarkly, if it is known.
150+
151+
This is only available once a connection to LaunchDarkly has provided
152+
it, and it will be None when the SDK is offline, using an unsupported
153+
data source, or connected to a service which does not report it.
154+
"""
155+
raise NotImplementedError
156+
145157

146158
class DiagnosticAccumulator(Protocol):
147159
def record_stream_init(self, timestamp, duration, failed):

ldclient/impl/datasystem/fdv1.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,10 @@ def stop(self):
9898
def store(self) -> ReadOnlyStore:
9999
return self._store_wrapper
100100

101+
@property
102+
def environment_id(self) -> Optional[str]:
103+
return self._data_source_update_sink.environment_id
104+
101105
def set_diagnostic_accumulator(self, diagnostic_accumulator: DiagnosticAccumulator):
102106
"""
103107
Sets the diagnostic accumulator for streaming initialization metrics.

ldclient/impl/datasystem/fdv2.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,12 @@
2121
from ldclient.impl.listeners import Listeners
2222
from ldclient.impl.repeating_task import RepeatingTask
2323
from ldclient.impl.rwlock import ReadWriteLock
24-
from ldclient.impl.util import _LD_FD_FALLBACK_HEADER, _Fail, log
24+
from ldclient.impl.util import (
25+
_LD_ENVID_HEADER,
26+
_LD_FD_FALLBACK_HEADER,
27+
_Fail,
28+
log
29+
)
2530
from ldclient.interfaces import (
2631
DataSourceErrorInfo,
2732
DataSourceErrorKind,
@@ -95,6 +100,7 @@ def __init__(
95100
self._lock = ReadWriteLock()
96101
self._active_synchronizer: Optional[Synchronizer] = None
97102
self._threads: List[Thread] = []
103+
self._environment_id: Optional[str] = None
98104

99105
# Track configuration
100106
self._configured_with_data_sources = (
@@ -216,6 +222,8 @@ def _run_initializers(self, set_on_ready: Event) -> bool:
216222

217223
if isinstance(basis_result, _Fail):
218224
log.warning("Initializer %s failed: %s", initializer.name, basis_result.error)
225+
if basis_result.headers is not None:
226+
self._record_environment_id(basis_result.headers.get(_LD_ENVID_HEADER))
219227
# An error response can still carry the FDv1 fallback directive.
220228
if basis_result.headers is not None and \
221229
basis_result.headers.get(_LD_FD_FALLBACK_HEADER) == 'true':
@@ -240,6 +248,8 @@ def _run_initializers(self, set_on_ready: Event) -> bool:
240248
basis = basis_result.value
241249
log.info("Initialized via %s", initializer.name)
242250

251+
self._record_environment_id(basis.environment_id)
252+
243253
# Apply the basis to the store
244254
self._store.apply(basis.change_set, basis.persist)
245255

@@ -412,6 +422,8 @@ def reader(self: 'FDv2'):
412422
if self._stop_event.is_set():
413423
return ConditionDirective.FALLBACK
414424

425+
self._record_environment_id(update.environment_id)
426+
415427
# Handle the update
416428
if update.change_set is not None:
417429
self._store.apply(update.change_set, True)
@@ -496,6 +508,19 @@ def _persistent_store_outage_recovery(self, data_store_status: DataStoreStatus):
496508
if err is not None:
497509
log.error("Failed to reinitialize data store", exc_info=err)
498510

511+
def _record_environment_id(self, environment_id: Optional[str]):
512+
if environment_id is None:
513+
return
514+
515+
with self._lock.write():
516+
self._environment_id = environment_id
517+
518+
@property
519+
def environment_id(self) -> Optional[str]:
520+
"""Get the environment ID reported by LaunchDarkly, if known."""
521+
with self._lock.read():
522+
return self._environment_id
523+
499524
@property
500525
def store(self) -> ReadOnlyStore:
501526
"""Get the underlying store for flag evaluation."""

0 commit comments

Comments
 (0)