├── .release-please-manifest.json ├── .sdk_metadata.json ├── LICENSE.txt ├── README.md ├── build └── .gitignore ├── composer.json ├── release-please-config.json └── src └── LaunchDarkly ├── BigSegmentsEvaluationStatus.php ├── EvaluationDetail.php ├── EvaluationReason.php ├── FeatureFlagsState.php ├── Impl ├── BigSegments │ ├── MembershipResult.php │ ├── StoreManager.php │ └── StoreStatusProvider.php ├── Evaluation │ ├── EvalResult.php │ ├── EvaluationException.php │ ├── Evaluator.php │ ├── EvaluatorBucketing.php │ ├── EvaluatorHelpers.php │ ├── EvaluatorState.php │ ├── InvalidAttributeReferenceException.php │ ├── Operators.php │ └── PrerequisiteEvaluationRecord.php ├── Events │ ├── EventFactory.php │ ├── EventProcessor.php │ ├── EventSerializer.php │ └── NullEventProcessor.php ├── Integrations │ ├── ApcuFeatureRequesterCache.php │ ├── CurlEventPublisher.php │ ├── FeatureRequesterBase.php │ ├── FeatureRequesterCache.php │ ├── FileDataFeatureRequester.php │ ├── GuzzleEventPublisher.php │ └── GuzzleFeatureRequester.php ├── Migrations │ └── Executor.php ├── Model │ ├── Clause.php │ ├── FeatureFlag.php │ ├── MigrationSettings.php │ ├── Prerequisite.php │ ├── Rollout.php │ ├── Rule.php │ ├── Segment.php │ ├── SegmentRule.php │ ├── SegmentTarget.php │ ├── Target.php │ ├── VariationOrRollout.php │ └── WeightedVariation.php ├── PreloadedFeatureRequester.php ├── SemanticVersion.php ├── UnrecoverableHTTPStatusException.php └── Util.php ├── Integrations ├── Curl.php ├── Files.php ├── Guzzle.php ├── TestData.php └── TestData │ ├── FlagBuilder.php │ ├── FlagRuleBuilder.php │ └── MigrationSettingsBuilder.php ├── LDClient.php ├── LDContext.php ├── LDContextBuilder.php ├── LDContextMultiBuilder.php ├── Migrations ├── ExecutionOrder.php ├── MigrationConfig.php ├── Migrator.php ├── MigratorBuilder.php ├── OpTracker.php ├── Operation.php ├── OperationResult.php ├── Origin.php ├── Stage.php └── WriteResult.php ├── Subsystems ├── BigSegmentStatusListener.php ├── BigSegmentStatusProvider.php ├── BigSegmentsStore.php ├── EventPublisher.php └── FeatureRequester.php └── Types ├── ApplicationInfo.php ├── AttributeReference.php ├── BigSegmentsConfig.php ├── BigSegmentsStoreMetadata.php ├── BigSegmentsStoreStatus.php └── Result.php /.release-please-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | ".": "6.6.0" 3 | } 4 | -------------------------------------------------------------------------------- /.sdk_metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "sdks": { 4 | "php-server-sdk": { 5 | "name": "PHP SDK", 6 | "type": "server-side", 7 | "languages": [ 8 | "PHP" 9 | ], 10 | "userAgents": ["PHPClient"] 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2016 Catamorphic, Co. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LaunchDarkly Server-side SDK for PHP 2 | 3 | [![Run CI](https://github.com/launchdarkly/php-server-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/launchdarkly/php-server-sdk/actions/workflows/ci.yml) 4 | [![Packagist](https://img.shields.io/packagist/v/launchdarkly/server-sdk.svg?style=flat-square)](https://packagist.org/packages/launchdarkly/server-sdk) 5 | [![Documentation](https://img.shields.io/static/v1?label=GitHub+Pages&message=API+reference&color=00add8)](https://launchdarkly.github.io/php-server-sdk) 6 | 7 | ## LaunchDarkly overview 8 | 9 | [LaunchDarkly](https://www.launchdarkly.com) is a feature management platform that serves trillions of feature flags daily to help teams build better software, faster. [Get started](https://docs.launchdarkly.com/home/getting-started) using LaunchDarkly today! 10 | 11 | [![Twitter Follow](https://img.shields.io/twitter/follow/launchdarkly.svg?style=social&label=Follow&maxAge=2592000)](https://twitter.com/intent/follow?screen_name=launchdarkly) 12 | 13 | ## Supported PHP versions 14 | 15 | This version of the LaunchDarkly SDK is compatible with PHP 8.1 and higher. 16 | 17 | ## Getting started 18 | 19 | Refer to the [SDK reference guide](https://docs.launchdarkly.com/sdk/server-side/php) for instructions on getting started with using the SDK. 20 | 21 | ## Learn more 22 | 23 | Read our [documentation](http://docs.launchdarkly.com) for in-depth instructions on configuring and using LaunchDarkly. You can also head straight to the [complete reference guide for this SDK](https://docs.launchdarkly.com/sdk/server-side/php). 24 | 25 | The authoritative description of all types, properties, and methods is in the [generated API documentation](http://launchdarkly.github.io/php-server-sdk/). 26 | 27 | ## Testing 28 | 29 | We run integration tests for all our SDKs using a centralized test harness. This approach gives us the ability to test for consistency across SDKs, as well as test networking behavior in a long-running application. These tests cover each method in the SDK, and verify that event sending, flag evaluation, stream reconnection, and other aspects of the SDK all behave correctly. 30 | 31 | ## Contributing 32 | 33 | We encourage pull requests and other contributions from the community. Check out our [contributing guidelines](CONTRIBUTING.md) for instructions on how to contribute to this SDK. 34 | 35 | ## About LaunchDarkly 36 | 37 | * LaunchDarkly is a continuous delivery platform that provides feature flags as a service and allows developers to iterate quickly and safely. We allow you to easily flag your features and manage them from the LaunchDarkly dashboard. With LaunchDarkly, you can: 38 | * Roll out a new feature to a subset of your users (like a group of users who opt-in to a beta tester group), gathering feedback and bug reports from real-world use cases. 39 | * Gradually roll out a feature to an increasing percentage of users, and track the effect that the feature has on key metrics (for instance, how likely is a user to complete a purchase if they have feature A versus feature B?). 40 | * Turn off a feature that you realize is causing performance problems in production, without needing to re-deploy, or even restart the application with a changed configuration file. 41 | * Grant access to certain features based on user attributes, like payment plan (eg: users on the ‘gold’ plan get access to more features than users in the ‘silver’ plan). Disable parts of your application to facilitate maintenance, without taking everything offline. 42 | * LaunchDarkly provides feature flag SDKs for a wide variety of languages and technologies. Read [our documentation](https://docs.launchdarkly.com/sdk) for a complete list. 43 | * Explore LaunchDarkly 44 | * [launchdarkly.com](https://www.launchdarkly.com/ "LaunchDarkly Main Website") for more information 45 | * [docs.launchdarkly.com](https://docs.launchdarkly.com/ "LaunchDarkly Documentation") for our documentation and SDK reference guides 46 | * [apidocs.launchdarkly.com](https://apidocs.launchdarkly.com/ "LaunchDarkly API Documentation") for our API documentation 47 | * [blog.launchdarkly.com](https://blog.launchdarkly.com/ "LaunchDarkly Blog Documentation") for the latest product updates 48 | -------------------------------------------------------------------------------- /build/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "launchdarkly/server-sdk", 3 | "description": "Official LaunchDarkly SDK for PHP", 4 | "keywords": [ 5 | "launchdarkly", 6 | "launchdarkly php" 7 | ], 8 | "homepage": "https://github.com/launchdarkly/php-server-sdk", 9 | "license": "Apache-2.0", 10 | "authors": [ 11 | { 12 | "name": "LaunchDarkly ", 13 | "homepage": "http://launchdarkly.com/" 14 | } 15 | ], 16 | "require": { 17 | "php": ">=8.1", 18 | "monolog/monolog": "^2.0|^3.0", 19 | "psr/cache": "^3.0", 20 | "psr/log": "^1.0|^2.0|^3.0", 21 | "ramsey/uuid": "^4.7" 22 | }, 23 | "require-dev": { 24 | "friendsofphp/php-cs-fixer": "^3.15.0", 25 | "guzzlehttp/guzzle": "^7", 26 | "kevinrob/guzzle-cache-middleware": "^4.0", 27 | "phpunit/php-code-coverage": "^9", 28 | "phpunit/phpunit": "^9", 29 | "vimeo/psalm": "^5.15" 30 | }, 31 | "suggest": { 32 | "guzzlehttp/guzzle": "(^6.3 | ^7) Required when using GuzzleEventPublisher or the default FeatureRequester", 33 | "kevinrob/guzzle-cache-middleware": "(^3) Recommended for performance when using the default FeatureRequester" 34 | }, 35 | "autoload": { 36 | "psr-4": { 37 | "LaunchDarkly\\": "src/LaunchDarkly/" 38 | } 39 | }, 40 | "autoload-dev": { 41 | "psr-4": { 42 | "LaunchDarkly\\Tests\\": "tests/" 43 | } 44 | }, 45 | "config": { 46 | "sort-packages": true 47 | }, 48 | "scripts": { 49 | "cs-check": "vendor/bin/php-cs-fixer fix --diff --dry-run --verbose --config=.php-cs-fixer.php", 50 | "cs-fix": "vendor/bin/php-cs-fixer fix --diff --verbose --config=.php-cs-fixer.php" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /release-please-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": { 3 | ".": { 4 | "release-type": "php", 5 | "bump-minor-pre-major": true, 6 | "versioning": "default", 7 | "include-v-in-tag": false, 8 | "include-component-in-tag": false, 9 | "extra-files": [ 10 | ".github/actions/build-docs/action.yml", 11 | "src/LaunchDarkly/LDClient.php" 12 | ] 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/LaunchDarkly/BigSegmentsEvaluationStatus.php: -------------------------------------------------------------------------------- 1 | _value = $value; 27 | $this->_variationIndex = $variationIndex; 28 | $this->_reason = $reason; 29 | } 30 | 31 | /** 32 | * Returns the result of the flag evaluation. This will be either one of the flag's variations or the default 33 | * value that was passed to the {@see \LaunchDarkly\LDClient::variationDetail()} method. 34 | * 35 | * @return mixed the flag value 36 | */ 37 | public function getValue(): mixed 38 | { 39 | return $this->_value; 40 | } 41 | 42 | /** 43 | * The index of the returned value within the flag's list of variations, e.g. 0 for the first variation-- 44 | * or null if it was the default value (evaluation failed). 45 | * 46 | * @return ?int the variation index if applicable 47 | */ 48 | public function getVariationIndex(): ?int 49 | { 50 | return $this->_variationIndex; 51 | } 52 | 53 | /** 54 | * Returns information about how the flag value was calculated. 55 | * 56 | * @return EvaluationReason 57 | */ 58 | public function getReason(): EvaluationReason 59 | { 60 | return $this->_reason; 61 | } 62 | 63 | /** 64 | * Returns true if the flag evaluated to the default value, rather than one of its variations. 65 | * 66 | * @return bool 67 | */ 68 | public function isDefaultValue(): bool 69 | { 70 | return ($this->_variationIndex === null); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/LaunchDarkly/EvaluationReason.php: -------------------------------------------------------------------------------- 1 | _kind = $kind; 172 | $this->_errorKind = $errorKind; 173 | $this->_ruleIndex = $ruleIndex; 174 | $this->_ruleId = $ruleId; 175 | $this->_prerequisiteKey = $prerequisiteKey; 176 | $this->_inExperiment = $inExperiment; 177 | $this->_bigSegmentsEvaluationStatus = $bigSegmentsEvaluationStatus; 178 | } 179 | 180 | /** 181 | * Returns a new EvaluationReason instance matching all the properties of 182 | * this one, except for the big segments evaluation status. 183 | */ 184 | public function withBigSegmentsEvaluationStatus(BigSegmentsEvaluationStatus $bigSegmentsEvaluationStatus): EvaluationReason 185 | { 186 | if ($this->_bigSegmentsEvaluationStatus == $bigSegmentsEvaluationStatus) { 187 | return $this; 188 | } 189 | 190 | return new EvaluationReason( 191 | $this->_kind, 192 | $this->_errorKind, 193 | $this->_ruleIndex, 194 | $this->_ruleId, 195 | $this->_prerequisiteKey, 196 | $this->_inExperiment, 197 | $bigSegmentsEvaluationStatus 198 | ); 199 | } 200 | 201 | /** 202 | * Returns a constant indicating the general category of the reason, such as OFF. 203 | * @return string 204 | */ 205 | public function getKind(): string 206 | { 207 | return $this->_kind; 208 | } 209 | 210 | /** 211 | * Returns a constant indicating the nature of the error, if getKind() is OFF. Otherwise 212 | * returns null. 213 | * @return string|null 214 | */ 215 | public function getErrorKind(): ?string 216 | { 217 | return $this->_errorKind; 218 | } 219 | 220 | /** 221 | * Returns the positional index of the rule that was matched (0 for the first), if getKind() 222 | * is RULE_MATCH. Otherwise returns null. 223 | * @return int|null 224 | */ 225 | public function getRuleIndex(): ?int 226 | { 227 | return $this->_ruleIndex; 228 | } 229 | 230 | /** 231 | * Returns the unique identifier of the rule that was matched, if getKind() is RULE_MATCH. 232 | * Otherwise returns null. 233 | * @return string|null 234 | */ 235 | public function getRuleId(): ?string 236 | { 237 | return $this->_ruleId; 238 | } 239 | 240 | /** 241 | * Returns the key of the prerequisite feature flag that failed, if getKind() is 242 | * PREREQUISITE_FAILED. Otherwise returns null. 243 | * @return string|null 244 | */ 245 | public function getPrerequisiteKey(): ?string 246 | { 247 | return $this->_prerequisiteKey; 248 | } 249 | 250 | /** 251 | * Returns true if the evaluation resulted in an experiment rollout *and* served 252 | * one of the variations in the experiment. Otherwise it returns false. 253 | * @return bool 254 | */ 255 | public function isInExperiment(): bool 256 | { 257 | return $this->_inExperiment; 258 | } 259 | 260 | /** 261 | * Describes the validity of Big Segment information, if and only if the 262 | * flag evaluation required querying at least one Big Segment. Otherwise it 263 | * returns null. Possible values are defined by {@see 264 | * BigSegmentsEvaluationStatus}. 265 | * 266 | * Big Segments are a specific kind of context segments. For more 267 | * information, read the LaunchDarkly documentation: 268 | * https://docs.launchdarkly.com/home/users/big-segments 269 | */ 270 | public function bigSegmentsEvaluationStatus(): ?BigSegmentsEvaluationStatus 271 | { 272 | return $this->_bigSegmentsEvaluationStatus; 273 | } 274 | 275 | /** 276 | * Returns a simple string representation of this object. 277 | */ 278 | public function __toString(): string 279 | { 280 | switch ($this->_kind) { 281 | case self::RULE_MATCH: 282 | return $this->_kind . '(' . ($this->_ruleIndex ?: 0) . ',' . ($this->_ruleId ?: '') . ')'; 283 | case self::PREREQUISITE_FAILED: 284 | return $this->_kind . '(' . ($this->_prerequisiteKey ?: '') . ')'; 285 | case self::ERROR: 286 | return $this->_kind . '(' . ($this->_errorKind ?: '') . ')'; 287 | default: 288 | return $this->_kind; 289 | } 290 | } 291 | 292 | /** 293 | * Returns a JSON representation of this object. This method is used automatically 294 | * if you call json_encode(). 295 | */ 296 | public function jsonSerialize(): array 297 | { 298 | $ret = ['kind' => $this->_kind]; 299 | if ($this->_errorKind !== null) { 300 | $ret['errorKind'] = $this->_errorKind; 301 | } 302 | if ($this->_ruleIndex !== null) { 303 | $ret['ruleIndex'] = $this->_ruleIndex; 304 | } 305 | if ($this->_ruleId !== null) { 306 | $ret['ruleId'] = $this->_ruleId; 307 | } 308 | if ($this->_prerequisiteKey !== null) { 309 | $ret['prerequisiteKey'] = $this->_prerequisiteKey; 310 | } 311 | if ($this->_inExperiment) { 312 | $ret['inExperiment'] = $this->_inExperiment; 313 | } 314 | if ($this->_bigSegmentsEvaluationStatus !== null) { 315 | $ret['bigSegmentsStatus'] = $this->_bigSegmentsEvaluationStatus->value; 316 | } 317 | return $ret; 318 | } 319 | } 320 | -------------------------------------------------------------------------------- /src/LaunchDarkly/FeatureFlagsState.php: -------------------------------------------------------------------------------- 1 | _valid = $valid; 30 | $this->_flagValues = []; 31 | $this->_flagMetadata = []; 32 | } 33 | 34 | /** 35 | * Used internally to build the state map. 36 | * 37 | * @ignore 38 | * 39 | * @return void 40 | */ 41 | public function addFlag( 42 | FeatureFlag $flag, 43 | EvaluationDetail $detail, 44 | bool $forceReasonTracking = false, 45 | bool $withReason = false, 46 | bool $detailsOnlyIfTracked = false, 47 | ?array $prerequisites = null, 48 | ): void { 49 | $this->_flagValues[$flag->getKey()] = $detail->getValue(); 50 | $meta = []; 51 | 52 | $trackEvents = $flag->isTrackEvents() || $forceReasonTracking; 53 | $trackReason = $forceReasonTracking; 54 | 55 | $omitDetails = false; 56 | if ($detailsOnlyIfTracked) { 57 | if (!$trackEvents && !$trackReason && !$flag->getDebugEventsUntilDate()) { 58 | $omitDetails = true; 59 | } 60 | } 61 | 62 | $reason = (!$withReason && !$trackReason) ? null : $detail->getReason(); 63 | 64 | if ($prerequisites) { 65 | $meta['prerequisites'] = $prerequisites; 66 | } 67 | if ($reason && !$omitDetails) { 68 | $meta['reason'] = $reason; 69 | } 70 | if (!$omitDetails) { 71 | $meta['version'] = $flag->getVersion(); 72 | } 73 | if (!is_null($detail->getVariationIndex())) { 74 | $meta['variation'] = $detail->getVariationIndex(); 75 | } 76 | if ($trackEvents) { 77 | $meta['trackEvents'] = true; 78 | } 79 | if ($trackReason) { 80 | $meta['trackReason'] = true; 81 | } 82 | if ($flag->getDebugEventsUntilDate()) { 83 | $meta['debugEventsUntilDate'] = $flag->getDebugEventsUntilDate(); 84 | } 85 | $this->_flagMetadata[$flag->getKey()] = $meta; 86 | } 87 | 88 | /** 89 | * Returns true if this object contains a valid snapshot of feature flag state, or false if the 90 | * state could not be computed (for instance, because the client was offline or there was no user). 91 | * @return bool true if the state is valid 92 | */ 93 | public function isValid(): bool 94 | { 95 | return $this->_valid; 96 | } 97 | 98 | /** 99 | * Returns the value of an individual feature flag at the time the state was recorded. 100 | * @param string $key the feature flag key 101 | * @return mixed the flag's value; null if the flag returned the default value, or if there was no such flag 102 | */ 103 | public function getFlagValue(string $key): mixed 104 | { 105 | return $this->_flagValues[$key] ?? null; 106 | } 107 | 108 | /** 109 | * Returns the evaluation reason for an individual feature flag (as returned by variationDetail()) 110 | * at the time the state was recorded. 111 | * @param string $key the feature flag key 112 | * @return EvaluationReason|null the evaluation reason; null if reasons were not recorded, or if there 113 | * was no such flag 114 | * @see \LaunchDarkly\LDClient::variationDetail() 115 | */ 116 | public function getFlagReason(string $key): ?EvaluationReason 117 | { 118 | return ($this->_flagMetadata[$key] ?? [])['reason'] ?? null; 119 | } 120 | 121 | /** 122 | * Returns an associative array of flag keys to flag values. 123 | * 124 | * If a flag would have evaluated to the default value, its value will be null. 125 | * 126 | * Do not use this method if you are passing data to the front end to "bootstrap" the JavaScript client. 127 | * Instead, use jsonSerialize(). 128 | * @return array an associative array of flag keys to JSON values 129 | */ 130 | public function toValuesMap(): array 131 | { 132 | return $this->_flagValues; 133 | } 134 | 135 | /** 136 | * Returns a JSON representation of the entire state map (as an associative array), in the format used 137 | * by the LaunchDarkly JavaScript SDK. 138 | * 139 | * Use this method if you are passing data to the front end in order to "bootstrap" the JavaScript client. 140 | * 141 | * Note that calling json_encode() on a FeatureFlagsState object will automatically use the 142 | * jsonSerialize() method. 143 | * @return array an associative array suitable for passing as a JSON object 144 | */ 145 | public function jsonSerialize(): array 146 | { 147 | $ret = array_replace([], $this->_flagValues); 148 | if (count($this->_flagMetadata) === 0) { 149 | $metaMap = new \stdClass(); // using object rather than array ensures the JSON value is {}, not [] 150 | } else { 151 | $metaMap = []; 152 | foreach ($this->_flagMetadata as $key => $meta) { 153 | $meta = array_replace([], $meta); 154 | if ($meta['reason'] ?? null) { 155 | $meta['reason'] = $meta['reason']->jsonSerialize(); 156 | } 157 | $metaMap[$key] = $meta; 158 | } 159 | } 160 | $ret['$flagsState'] = $metaMap; 161 | $ret['$valid'] = $this->_valid; 162 | return $ret; 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Impl/BigSegments/MembershipResult.php: -------------------------------------------------------------------------------- 1 | $membership A map from segment reference to 13 | * inclusion status (true if the context is included, false if excluded). 14 | * If null, the membership could not be retrieved. 15 | */ 16 | public function __construct( 17 | public readonly ?array $membership, 18 | public readonly BigSegmentsEvaluationStatus $status 19 | ) { 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Impl/BigSegments/StoreManager.php: -------------------------------------------------------------------------------- 1 | config = $config; 26 | $this->store = $config->store; 27 | $this->statusProvider = new Impl\BigSegments\StoreStatusProvider( 28 | fn () => $this->pollAndUpdateStatus(), 29 | $logger 30 | ); 31 | $this->lastStatus = null; 32 | $this->lastStatusPollTime = null; 33 | } 34 | 35 | public function getStatusProvider(): Subsystems\BigSegmentStatusProvider 36 | { 37 | return $this->statusProvider; 38 | } 39 | 40 | /** 41 | * Retrieves the membership of a context key in a Big Segment, if a backing 42 | * big segments store has been configured. 43 | */ 44 | public function getContextMembership(string $contextKey): ?Impl\BigSegments\MembershipResult 45 | { 46 | if ($this->store === null) { 47 | return null; 48 | } 49 | 50 | $cachedItem = null; 51 | try { 52 | $cachedItem = $this->config->cache?->getItem($contextKey); 53 | } catch (Exception $e) { 54 | $this->logger->warning("Failed to retrieve cached item for big segment", ['contextKey' => $contextKey, 'exception' => $e->getMessage()]); 55 | } 56 | /** @var ?array */ 57 | $membership = $cachedItem?->get(); 58 | 59 | if ($membership === null) { 60 | try { 61 | $membership = $this->store->getMembership(StoreManager::hashForContextKey($contextKey)); 62 | if ($this->config->cache !== null && $cachedItem !== null) { 63 | $cachedItem->set($membership)->expiresAfter($this->config->contextCacheTime); 64 | 65 | if (!$this->config->cache->save($cachedItem)) { 66 | $this->logger->warning("Failed to save Big Segment membership to cache", ['contextKey' => $contextKey]); 67 | } 68 | } 69 | } catch (Exception $e) { 70 | $this->logger->warning("Failed to retrieve Big Segment membership", ['contextKey' => $contextKey, 'exception' => $e->getMessage()]); 71 | return new Impl\BigSegments\MembershipResult(null, BigSegmentsEvaluationStatus::STORE_ERROR); 72 | } 73 | } 74 | 75 | $nextPollingTime = ($this->lastStatusPollTime?->getTimestamp() ?? 0) + $this->config->statusPollInterval; 76 | 77 | $status = $this->lastStatus; 78 | if ($this->lastStatusPollTime === null || $nextPollingTime < time()) { 79 | $status = $this->pollAndUpdateStatus(); 80 | } 81 | 82 | if ($status === null || !$status->isAvailable()) { 83 | return new Impl\BigSegments\MembershipResult($membership, BigSegmentsEvaluationStatus::STORE_ERROR); 84 | } 85 | 86 | return new Impl\BigSegments\MembershipResult($membership, $status->isStale() ? BigSegmentsEvaluationStatus::STALE : BigSegmentsEvaluationStatus::HEALTHY); 87 | } 88 | 89 | private function pollAndUpdateStatus(): Types\BigSegmentsStoreStatus 90 | { 91 | $newStatus = new Types\BigSegmentsStoreStatus(false, false); 92 | if ($this->store !== null) { 93 | try { 94 | $metadata = $this->store->getMetadata(); 95 | $newStatus = new Types\BigSegmentsStoreStatus( 96 | available: true, 97 | stale: $metadata->isStale($this->config->staleAfter) 98 | ); 99 | } catch (Exception $e) { 100 | $this->logger->warning("Failed to retrieve Big Segment metadata", ['exception' => $e->getMessage()]); 101 | } 102 | } 103 | 104 | $this->lastStatus = $newStatus; 105 | $this->statusProvider->updateStatus($newStatus); 106 | $this->lastStatusPollTime = new DateTimeImmutable(); 107 | 108 | return $newStatus; 109 | } 110 | 111 | private static function hashForContextKey(string $contextKey): string 112 | { 113 | return base64_encode(hash('sha256', $contextKey, true)); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Impl/BigSegments/StoreStatusProvider.php: -------------------------------------------------------------------------------- 1 | listeners = new SplObjectStorage(); 29 | $this->statusFn = $statusFn; 30 | $this->lastStatus = null; 31 | $this->logger = $logger; 32 | } 33 | 34 | public function attach(Subsystems\BigSegmentStatusListener $listener): void 35 | { 36 | $this->listeners->attach($listener); 37 | } 38 | 39 | public function detach(Subsystems\BigSegmentStatusListener $listener): void 40 | { 41 | $this->listeners->detach($listener); 42 | } 43 | 44 | /** 45 | * @internal 46 | */ 47 | public function updateStatus(Types\BigSegmentsStoreStatus $status): void 48 | { 49 | if ($this->lastStatus != $status) { 50 | $old = $this->lastStatus; 51 | $this->lastStatus = $status; 52 | 53 | $this->notify(old: $old, new: $status); 54 | } 55 | } 56 | 57 | private function notify(?Types\BigSegmentsStoreStatus $old, Types\BigSegmentsStoreStatus $new): void 58 | { 59 | /** @var Subsystems\BigSegmentStatusListener $listener */ 60 | foreach ($this->listeners as $listener) { 61 | try { 62 | $listener->statusChanged($old, $new); 63 | } catch (Exception $e) { 64 | $this->logger->warning('A big segments status listener threw an exception', ['exception' => $e->getMessage()]); 65 | } 66 | } 67 | } 68 | 69 | public function lastStatus(): ?Types\BigSegmentsStoreStatus 70 | { 71 | return $this->lastStatus; 72 | } 73 | 74 | public function status(): Types\BigSegmentsStoreStatus 75 | { 76 | return ($this->statusFn)(); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Impl/Evaluation/EvalResult.php: -------------------------------------------------------------------------------- 1 | _detail = $detail; 28 | $this->_state = $state; 29 | $this->_forceReasonTracking = $forceReasonTracking; 30 | } 31 | 32 | public function withState(EvaluatorState $state): EvalResult 33 | { 34 | return new EvalResult($this->_detail, $this->_forceReasonTracking, $state); 35 | } 36 | 37 | public function withDetail(EvaluationDetail $detail): EvalResult 38 | { 39 | return new EvalResult($detail, $this->_forceReasonTracking, $this->_state); 40 | } 41 | 42 | public function getDetail(): EvaluationDetail 43 | { 44 | return $this->_detail; 45 | } 46 | 47 | public function getState(): ?EvaluatorState 48 | { 49 | return $this->_state; 50 | } 51 | 52 | public function isForceReasonTracking(): bool 53 | { 54 | return $this->_forceReasonTracking; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Impl/Evaluation/EvaluationException.php: -------------------------------------------------------------------------------- 1 | _errorKind = $errorKind; 22 | } 23 | 24 | public function getErrorKind(): string 25 | { 26 | return $this->_errorKind; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Impl/Evaluation/EvaluatorBucketing.php: -------------------------------------------------------------------------------- 1 | getVariation(); 26 | if ($variation !== null) { 27 | return [$variation, false]; 28 | } 29 | $rollout = $vr->getRollout(); 30 | if ($rollout === null) { 31 | return [null, false]; 32 | } 33 | $variations = $rollout->getVariations(); 34 | if (count($variations) === 0) { 35 | return [null, false]; 36 | } 37 | 38 | $bucketBy = ($rollout->isExperiment() ? null : $rollout->getBucketBy()) ?: 'key'; 39 | $bucket = self::getBucketValueForContext( 40 | $context, 41 | $rollout->getContextKind(), 42 | $_key, 43 | $bucketBy, 44 | $_salt, 45 | $rollout->getSeed() 46 | ); 47 | $experiment = $rollout->isExperiment() && $bucket >= 0; 48 | // getBucketValueForContext returns a negative value if the context didn't exist, in which case we 49 | // still end up returning the first bucket, but we will force the "in experiment" state to be false. 50 | 51 | $sum = 0.0; 52 | foreach ($variations as $wv) { 53 | $sum += $wv->getWeight() / 100000.0; 54 | if ($bucket < $sum) { 55 | return [$wv->getVariation(), $experiment && !$wv->isUntracked()]; 56 | } 57 | } 58 | $lastVariation = $variations[count($variations) - 1]; 59 | return [$lastVariation->getVariation(), $experiment && !$lastVariation->isUntracked()]; 60 | } 61 | 62 | public static function getBucketValueForContext( 63 | LDContext $context, 64 | ?string $contextKind, 65 | string $key, 66 | string $attr, 67 | ?string $salt, 68 | ?int $seed 69 | ): float { 70 | $matchContext = $context->getIndividualContext($contextKind ?? LDContext::DEFAULT_KIND); 71 | if ($matchContext === null) { 72 | return -1; 73 | } 74 | $contextValue = EvaluatorHelpers::getContextValueForAttributeReference($matchContext, $attr, $contextKind); 75 | if ($contextValue === null) { 76 | return 0.0; 77 | } 78 | if (is_int($contextValue)) { 79 | $contextValue = (string) $contextValue; 80 | } elseif (!is_string($contextValue)) { 81 | return 0.0; 82 | } 83 | $idHash = $contextValue; 84 | if (isset($seed)) { 85 | $prefix = (string) $seed; 86 | } else { 87 | $prefix = $key . "." . ($salt ?: ''); 88 | } 89 | $hash = substr(sha1($prefix . "." . $idHash), 0, 15); 90 | $longVal = (int)base_convert($hash, 16, 10); 91 | $result = $longVal / self::LONG_SCALE; 92 | 93 | return $result; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Impl/Evaluation/EvaluatorHelpers.php: -------------------------------------------------------------------------------- 1 | getIndividualContext($contextKind ?: LDContext::DEFAULT_KIND); 32 | return $matchContext !== null && in_array($matchContext->getKey(), $keys); 33 | } 34 | 35 | public static function evaluationDetailForVariation( 36 | FeatureFlag $flag, 37 | int $index, 38 | EvaluationReason $reason 39 | ): EvaluationDetail { 40 | $vars = $flag->getVariations(); 41 | if ($index < 0 || $index >= count($vars)) { 42 | return new EvaluationDetail(null, null, EvaluationReason::error(EvaluationReason::MALFORMED_FLAG_ERROR)); 43 | } 44 | return new EvaluationDetail($vars[$index], $index, $reason); 45 | } 46 | 47 | public static function getContextValueForAttributeReference( 48 | LDContext $context, 49 | string $attributeRef, 50 | ?string $forContextKind 51 | ): mixed { 52 | $parsed = ($forContextKind === null || $forContextKind === '') ? 53 | // If no context kind was specified, treat the attribute as just an attribute name, not a reference path 54 | AttributeReference::fromLiteral($attributeRef) : 55 | // If a context kind was specified, parse it as a path 56 | AttributeReference::fromPath($attributeRef); 57 | if (($err = $parsed->getError()) !== null) { 58 | throw new InvalidAttributeReferenceException($err); 59 | } 60 | $depth = $parsed->getDepth(); 61 | $value = $context->get($parsed->getComponent(0)); 62 | if ($depth <= 1) { 63 | return $value; 64 | } 65 | for ($i = 1; $i < $depth; $i++) { 66 | $propName = $parsed->getComponent($i); 67 | if (is_object($value)) { 68 | $value = get_object_vars($value)[$propName] ?? null; 69 | } elseif (is_array($value)) { 70 | // Note that either a JSON array or a JSON object could be represented as a PHP array. 71 | // There is no good way to distinguish between ["a", "b"] and {"0": "a", "1": "b"}. 72 | // Therefore, our lookup logic here is slightly more permissive than other SDKs, where 73 | // an attempt to get /attr/0 would only work in the second case and not in the first. 74 | $value = $value[$propName] ?? null; 75 | } else { 76 | return null; 77 | } 78 | } 79 | return $value; 80 | } 81 | 82 | public static function getOffResult(FeatureFlag $flag, EvaluationReason $reason): EvalResult 83 | { 84 | $offVar = $flag->getOffVariation(); 85 | if ($offVar === null) { 86 | return new EvalResult(new EvaluationDetail(null, null, $reason), false); 87 | } 88 | return new EvalResult(self::evaluationDetailForVariation($flag, $offVar, $reason), false); 89 | } 90 | 91 | public static function getResultForVariationOrRollout( 92 | FeatureFlag $flag, 93 | VariationOrRollout $r, 94 | bool $forceTracking, 95 | LDContext $context, 96 | EvaluationReason $reason 97 | ): EvalResult { 98 | try { 99 | list($index, $inExperiment) = EvaluatorBucketing::variationIndexForContext($r, $context, $flag->getKey(), $flag->getSalt()); 100 | } catch (InvalidAttributeReferenceException $e) { 101 | return new EvalResult(new EvaluationDetail(null, null, EvaluationReason::error(EvaluationReason::MALFORMED_FLAG_ERROR))); 102 | } 103 | if ($index === null) { 104 | return new EvalResult( 105 | new EvaluationDetail(null, null, EvaluationReason::error(EvaluationReason::MALFORMED_FLAG_ERROR)), 106 | false 107 | ); 108 | } 109 | if ($inExperiment) { 110 | if ($reason->getKind() === EvaluationReason::FALLTHROUGH) { 111 | $reason = EvaluationReason::fallthrough(true); 112 | } elseif ($reason->getKind() === EvaluationReason::RULE_MATCH) { 113 | $reason = EvaluationReason::ruleMatch($reason->getRuleIndex(), $reason->getRuleId(), true); 114 | } 115 | } 116 | return new EvalResult( 117 | EvaluatorHelpers::evaluationDetailForVariation($flag, $index, $reason), 118 | $inExperiment || $forceTracking 119 | ); 120 | } 121 | 122 | public static function matchClauseWithoutSegments(Clause $clause, LDContext $context): bool 123 | { 124 | $attr = $clause->getAttribute(); 125 | if ($attr === null) { 126 | return false; 127 | } 128 | if ($attr === 'kind') { 129 | return self::maybeNegate($clause, self::matchClauseByKind($clause, $context)); 130 | } 131 | $actualContext = $context->getIndividualContext($clause->getContextKind() ?? LDContext::DEFAULT_KIND); 132 | if ($actualContext === null) { 133 | return false; 134 | } 135 | $contextValue = self::getContextValueForAttributeReference($actualContext, $attr, $clause->getContextKind()); 136 | if ($contextValue === null) { 137 | return false; 138 | } 139 | if (is_array($contextValue)) { 140 | foreach ($contextValue as $element) { 141 | if (self::matchAnyClauseValue($clause, $element)) { 142 | return EvaluatorHelpers::maybeNegate($clause, true); 143 | } 144 | } 145 | return self::maybeNegate($clause, false); 146 | } else { 147 | return self::maybeNegate($clause, self::matchAnyClauseValue($clause, $contextValue)); 148 | } 149 | } 150 | 151 | private static function matchClauseByKind(Clause $clause, LDContext $context): bool 152 | { 153 | // If attribute is "kind", then we treat operator and values as a match expression against a list 154 | // of all individual kinds in the context. That is, for a multi-kind context with kinds of "org" 155 | // and "user", it is a match if either of those strings is a match with Operator and Values. 156 | for ($i = 0; $i < $context->getIndividualContextCount(); $i++) { 157 | $c = $context->getIndividualContext($i); 158 | if ($c !== null && self::matchAnyClauseValue($clause, $c->getKind())) { 159 | return true; 160 | } 161 | } 162 | return false; 163 | } 164 | 165 | private static function matchAnyClauseValue(Clause $clause, mixed $contextValue): bool 166 | { 167 | $op = $clause->getOp(); 168 | foreach ($clause->getValues() as $v) { 169 | $result = Operators::apply($op, $contextValue, $v); 170 | if ($result === true) { 171 | return true; 172 | } 173 | } 174 | return false; 175 | } 176 | 177 | public static function maybeNegate(Clause $clause, bool $b): bool 178 | { 179 | return $clause->isNegate() ? !$b : $b; 180 | } 181 | 182 | public static function targetMatchResult(FeatureFlag $flag, Target $t): EvalResult 183 | { 184 | return new EvalResult( 185 | self::evaluationDetailForVariation($flag, $t->getVariation(), EvaluationReason::targetMatch()), 186 | false 187 | ); 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Impl/Evaluation/EvaluatorState.php: -------------------------------------------------------------------------------- 1 | > 30 | */ 31 | public ?array $bigSegmentsMembership = null; 32 | 33 | public function __construct(public FeatureFlag $originalFlag) 34 | { 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Impl/Evaluation/InvalidAttributeReferenceException.php: -------------------------------------------------------------------------------- 1 | = 0 && strpos($u, $c, $temp) !== false); 45 | } 46 | break; 47 | case "startsWith": 48 | if (is_string($u) && is_string($c)) { 49 | return strpos($u, $c) === 0; 50 | } 51 | break; 52 | case "matches": 53 | if (is_string($u) && is_string($c)) { 54 | //PHP can do subpatterns, but everything needs to be wrapped in an outer (): 55 | return preg_match("($c)", $u) === 1; 56 | } 57 | break; 58 | case "contains": 59 | if (is_string($u) && is_string($c)) { 60 | return strpos($u, $c) !== false; 61 | } 62 | break; 63 | case "lessThan": 64 | if (self::is_numeric($u) && self::is_numeric($c)) { 65 | return $u < $c; 66 | } 67 | break; 68 | case "lessThanOrEqual": 69 | if (self::is_numeric($u) && self::is_numeric($c)) { 70 | return $u <= $c; 71 | } 72 | break; 73 | case "greaterThan": 74 | if (self::is_numeric($u) && self::is_numeric($c)) { 75 | return $u > $c; 76 | } 77 | break; 78 | case "greaterThanOrEqual": 79 | if (self::is_numeric($u) && self::is_numeric($c)) { 80 | return $u >= $c; 81 | } 82 | break; 83 | case "before": 84 | $uTime = self::parseTime($u); 85 | if ($uTime != null) { 86 | $cTime = self::parseTime($c); 87 | if ($cTime != null) { 88 | return $uTime < $cTime; 89 | } 90 | } 91 | break; 92 | case "after": 93 | $uTime = self::parseTime($u); 94 | if ($uTime != null) { 95 | $cTime = self::parseTime($c); 96 | if ($cTime != null) { 97 | return $uTime > $cTime; 98 | } 99 | } 100 | break; 101 | case "semVerEqual": 102 | return self::semver_operator($u, $c, 0); 103 | case "semVerLessThan": 104 | return self::semver_operator($u, $c, -1); 105 | case "semVerGreaterThan": 106 | return self::semver_operator($u, $c, 1); 107 | } 108 | } catch (Exception $ignored) { 109 | } 110 | return false; 111 | } 112 | 113 | private static function semver_operator(mixed $u, mixed $c, int $expectedComparisonResult): bool 114 | { 115 | if (!is_string($u) || !is_string($c)) { 116 | return false; 117 | } 118 | $uVer = self::parseSemVer($u); 119 | $cVer = self::parseSemVer($c); 120 | return ($uVer != null) && ($cVer != null) && $uVer->comparePrecedence($cVer) == $expectedComparisonResult; 121 | } 122 | 123 | /** 124 | * A stricter version of the built-in is_numeric checker. 125 | * 126 | * This version will check if the provided value is numeric, but isn't a 127 | * string. This helps us stay consistent with the way flag evaluations work 128 | * in more strictly typed languages. 129 | * 130 | * @param mixed $value 131 | * @return bool 132 | */ 133 | public static function is_numeric(mixed $value): bool 134 | { 135 | return is_numeric($value) && !is_string($value); 136 | } 137 | 138 | /** 139 | * @param mixed $in 140 | * @return ?int 141 | */ 142 | public static function parseTime(mixed $in): ?int 143 | { 144 | if (is_string($in)) { 145 | $dateTime = DateTime::createFromFormat(DateTimeInterface::RFC3339_EXTENDED, $in); 146 | if ($dateTime == null) { 147 | // try the same format but without fractional seconds 148 | $dateTime = DateTime::createFromFormat(DateTimeInterface::RFC3339, $in); 149 | } 150 | if ($dateTime == null) { 151 | return null; 152 | } 153 | return Util::dateTimeToUnixMillis($dateTime); 154 | } 155 | 156 | if (is_numeric($in)) { // check this after is_string, because a numeric string would return true 157 | return (int)$in; 158 | } 159 | 160 | if ($in instanceof DateTime) { 161 | return Util::dateTimeToUnixMillis($in); 162 | } 163 | 164 | return null; 165 | } 166 | 167 | public static function parseSemVer(string $in): ?SemanticVersion 168 | { 169 | try { 170 | return SemanticVersion::parse($in, true); 171 | } catch (\InvalidArgumentException $e) { 172 | return null; 173 | } 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Impl/Evaluation/PrerequisiteEvaluationRecord.php: -------------------------------------------------------------------------------- 1 | _flag = $flag; 25 | $this->_prereqOfFlag = $prereqOfFlag; 26 | $this->_result = $result; 27 | } 28 | 29 | public function getFlag(): FeatureFlag 30 | { 31 | return $this->_flag; 32 | } 33 | 34 | public function getPrereqOfFlag(): FeatureFlag 35 | { 36 | return $this->_prereqOfFlag; 37 | } 38 | 39 | public function getResult(): EvalResult 40 | { 41 | return $this->_result; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Impl/Events/EventFactory.php: -------------------------------------------------------------------------------- 1 | _withReasons = $withReasons; 24 | } 25 | 26 | /** 27 | * @param FeatureFlag $flag 28 | * @param LDContext $context 29 | * @param EvalResult $result 30 | * @param mixed $default 31 | * @param FeatureFlag|null $prereqOfFlag 32 | * @return mixed[] 33 | */ 34 | public function newEvalEvent( 35 | FeatureFlag $flag, 36 | LDContext $context, 37 | EvalResult $result, 38 | mixed $default, 39 | ?FeatureFlag $prereqOfFlag = null 40 | ): array { 41 | $detail = $result->getDetail(); 42 | $forceReasonTracking = $result->isForceReasonTracking(); 43 | $e = [ 44 | 'kind' => 'feature', 45 | 'creationDate' => Util::currentTimeUnixMillis(), 46 | 'key' => $flag->getKey(), 47 | 'context' => $context, 48 | 'variation' => $detail->getVariationIndex(), 49 | 'value' => $detail->getValue(), 50 | 'default' => $default, 51 | 'version' => $flag->getVersion() 52 | ]; 53 | 54 | // the following properties are handled separately so we don't waste bandwidth on unused keys 55 | if ($flag->getExcludeFromSummaries()) { 56 | $e['excludeFromSummaries'] = true; 57 | } 58 | if ($flag->getSamplingRatio() !== 1) { 59 | $e['samplingRatio'] = $flag->getSamplingRatio(); 60 | } 61 | if ($forceReasonTracking || $flag->isTrackEvents()) { 62 | $e['trackEvents'] = true; 63 | } 64 | if ($flag->getDebugEventsUntilDate()) { 65 | $e['debugEventsUntilDate'] = $flag->getDebugEventsUntilDate(); 66 | } 67 | if ($prereqOfFlag) { 68 | $e['prereqOf'] = $prereqOfFlag->getKey(); 69 | } 70 | if (($forceReasonTracking || $this->_withReasons)) { 71 | $e['reason'] = $detail->getReason()->jsonSerialize(); 72 | } 73 | return $e; 74 | } 75 | 76 | /** 77 | * @return mixed[] 78 | */ 79 | public function newUnknownFlagEvent(string $key, LDContext $context, EvaluationDetail $detail): array 80 | { 81 | $e = [ 82 | 'kind' => 'feature', 83 | 'creationDate' => Util::currentTimeUnixMillis(), 84 | 'key' => $key, 85 | 'context' => $context, 86 | 'value' => $detail->getValue(), 87 | 'default' => $detail->getValue() 88 | ]; 89 | // the following properties are handled separately so we don't waste bandwidth on unused keys 90 | if ($this->_withReasons) { 91 | $e['reason'] = $detail->getReason()->jsonSerialize(); 92 | } 93 | return $e; 94 | } 95 | 96 | /** 97 | * @return mixed[] 98 | */ 99 | public function newIdentifyEvent(LDContext $context): array 100 | { 101 | return [ 102 | 'kind' => 'identify', 103 | 'creationDate' => Util::currentTimeUnixMillis(), 104 | 'context' => $context 105 | ]; 106 | } 107 | 108 | /** 109 | * @return mixed[] 110 | */ 111 | public function newCustomEvent(string $eventName, LDContext $context, mixed $data, int|float|null $metricValue): array 112 | { 113 | $e = [ 114 | 'kind' => 'custom', 115 | 'creationDate' => Util::currentTimeUnixMillis(), 116 | 'key' => $eventName, 117 | 'context' => $context 118 | ]; 119 | if ($data !== null) { 120 | $e['data'] = $data; 121 | } 122 | if ($metricValue !== null) { 123 | $e['metricValue'] = $metricValue; 124 | } 125 | return $e; 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Impl/Events/EventProcessor.php: -------------------------------------------------------------------------------- 1 | $options 26 | */ 27 | public function __construct(string $sdkKey, array $options) 28 | { 29 | $this->_eventPublisher = $this->getEventPublisher($sdkKey, $options); 30 | $this->_eventSerializer = new EventSerializer($options); 31 | 32 | $this->_capacity = $options['capacity']; 33 | } 34 | 35 | public function __destruct() 36 | { 37 | $this->flush(); 38 | } 39 | 40 | public function sendEvent(array $event): bool 41 | { 42 | return $this->enqueue($event); 43 | } 44 | 45 | /** 46 | * @param mixed[] $event 47 | */ 48 | public function enqueue(array $event): bool 49 | { 50 | if (count($this->_queue) > $this->_capacity) { 51 | return false; 52 | } 53 | 54 | if (isset($event['samplingRatio'])) { 55 | $samplingRatio = $event['samplingRatio']; 56 | if (is_int($samplingRatio) && !Util::sample($samplingRatio)) { 57 | return false; 58 | } 59 | } 60 | 61 | $this->_queue[] = $event; 62 | 63 | return true; 64 | } 65 | 66 | /** 67 | * Publish events to LaunchDarkly 68 | * @return bool Whether the events were successfully published 69 | */ 70 | public function flush(): bool 71 | { 72 | if (empty($this->_queue)) { 73 | return false; 74 | } 75 | 76 | $payload = $this->_eventSerializer->serializeEvents($this->_queue); 77 | 78 | // We don't expect flush to be called more than once per request cycle, but let's empty the queue just in case 79 | $this->_queue = []; 80 | 81 | return $this->_eventPublisher->publish($payload); 82 | } 83 | 84 | /** 85 | * @psalm-suppress UndefinedClass 86 | */ 87 | private function getEventPublisher(string $sdkKey, array $options): EventPublisher 88 | { 89 | $ep = $options['event_publisher'] ?? null; 90 | if (!$ep) { 91 | $ep = Curl::eventPublisher(); 92 | } 93 | if ($ep instanceof EventPublisher) { 94 | return $ep; 95 | } 96 | if (is_callable($ep)) { 97 | return $ep($sdkKey, $options); 98 | } 99 | if (!is_a($ep, EventPublisher::class, true)) { 100 | throw new \InvalidArgumentException; 101 | } 102 | /** 103 | * @psalm-suppress LessSpecificReturnStatement 104 | */ 105 | return new $ep($sdkKey, $options); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Impl/Events/EventSerializer.php: -------------------------------------------------------------------------------- 1 | _allAttributesPrivate = !!($options['all_attributes_private'] ?? false); 25 | 26 | $allParsedPrivate = []; 27 | foreach ($options['private_attribute_names'] ?? [] as $attr) { 28 | $parsed = AttributeReference::fromPath($attr); 29 | if ($parsed->getError() === null) { 30 | $allParsedPrivate[] = $parsed; 31 | } 32 | } 33 | $this->_privateAttributes = $allParsedPrivate; 34 | } 35 | 36 | public function serializeEvents(array $events): string 37 | { 38 | $filtered = []; 39 | foreach ($events as $e) { 40 | $filtered[] = $this->filterEvent($e); 41 | } 42 | $ret = json_encode($filtered); 43 | if ($ret === false) { 44 | return ''; 45 | } 46 | return $ret; 47 | } 48 | 49 | private function filterEvent(array $e): array 50 | { 51 | $isFeatureEvent = ($e['kind'] ?? '') == 'feature'; 52 | 53 | $ret = []; 54 | foreach ($e as $key => $value) { 55 | if ($key == 'context') { 56 | $ret[$key] = $this->serializeContext($value, $isFeatureEvent); 57 | } else { 58 | $ret[$key] = $value; 59 | } 60 | } 61 | return $ret; 62 | } 63 | 64 | private function serializeContext(LDContext $context, bool $redactAnonymousAttributes): array 65 | { 66 | if ($context->isMultiple()) { 67 | $ret = ['kind' => 'multi']; 68 | for ($i = 0; $i < $context->getIndividualContextCount(); $i++) { 69 | $c = $context->getIndividualContext($i); 70 | if ($c !== null) { 71 | $ret[$c->getKind()] = $this->serializeContextSingleKind($c, false, $redactAnonymousAttributes); 72 | } 73 | } 74 | return $ret; 75 | } else { 76 | return $this->serializeContextSingleKind($context, true, $redactAnonymousAttributes); 77 | } 78 | } 79 | 80 | private function serializeContextSingleKind(LDContext $c, bool $includeKind, bool $redactAnonymousAttributes): array 81 | { 82 | $ret = ['key' => $c->getKey()]; 83 | if ($includeKind) { 84 | $ret['kind'] = $c->getKind(); 85 | } 86 | if ($c->isAnonymous()) { 87 | $ret['anonymous'] = true; 88 | } 89 | $redacted = []; 90 | $allPrivate = array_merge($this->_privateAttributes, $c->getPrivateAttributes() ?? []); 91 | $redactAllAttributes = $this->_allAttributesPrivate || ($redactAnonymousAttributes && $c->isAnonymous()); 92 | if ($c->getName() !== null && !$this->checkWholeAttributePrivate('name', $allPrivate, $redacted, $redactAllAttributes)) { 93 | $ret['name'] = $c->getName(); 94 | } 95 | foreach ($c->getCustomAttributeNames() as $attr) { 96 | if (!$this->checkWholeAttributePrivate($attr, $allPrivate, $redacted, $redactAllAttributes)) { 97 | $value = $c->get($attr); 98 | $ret[$attr] = self::redactJsonValue(null, $attr, $value, $allPrivate, $redacted); 99 | } 100 | } 101 | if (count($redacted) !== 0) { 102 | $ret['_meta'] = ['redactedAttributes' => $redacted]; 103 | } 104 | return $ret; 105 | } 106 | 107 | private function checkWholeAttributePrivate(string $attr, array $allPrivate, array &$redactedOut, bool $redactAllAttributes): bool 108 | { 109 | if ($redactAllAttributes) { 110 | $redactedOut[] = $attr; 111 | return true; 112 | } 113 | foreach ($allPrivate as $p) { 114 | if ($p->getComponent(0) === $attr && $p->getDepth() === 1) { 115 | $redactedOut[] = $attr; 116 | return true; 117 | } 118 | } 119 | return false; 120 | } 121 | 122 | private static function redactJsonValue(?array $parentPath, string $name, mixed $value, array $allPrivate, array &$redactedOut): mixed 123 | { 124 | if (!is_array($value) || count($value) === 0) { 125 | return $value; 126 | } 127 | $ret = []; 128 | $currentPath = $parentPath ?? []; 129 | $currentPath[] = $name; 130 | foreach ($value as $k => $v) { 131 | if (is_int($k)) { 132 | // This is a regular array, not an object with string properties-- redactions don't apply. Technically, 133 | // that's not a 100% solid assumption because in PHP, an array could have a mix of int and string keys. 134 | // But that's not true in JSON or in pretty much any other SDK, so there wouldn't really be any clear 135 | // way to apply our redaction logic in that case anyway. 136 | return $value; 137 | } 138 | $wasRedacted = false; 139 | foreach ($allPrivate as $p) { 140 | if ($p->getDepth() !== count($currentPath) + 1) { 141 | continue; 142 | } 143 | if ($p->getComponent(count($currentPath)) !== $k) { 144 | continue; 145 | } 146 | $match = true; 147 | for ($i = 0; $i < count($currentPath); $i++) { 148 | if ($p->getComponent($i) !== $currentPath[$i]) { 149 | $match = false; 150 | break; 151 | } 152 | } 153 | if ($match) { 154 | $redactedOut[] = $p->getPath(); 155 | $wasRedacted = true; 156 | break; 157 | } 158 | } 159 | if (!$wasRedacted) { 160 | $ret[$k] = self::redactJsonValue($currentPath, $k, $v, $allPrivate, $redactedOut); 161 | } 162 | } 163 | if (count($ret) === 0) { 164 | // Substitute an empty object here, because an empty array would serialize as [] rather than {} 165 | return new \stdClass(); 166 | } 167 | return $ret; 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Impl/Events/NullEventProcessor.php: -------------------------------------------------------------------------------- 1 | _expiration = $expiration; 24 | } 25 | 26 | public function getCachedString(string $cacheKey): ?string 27 | { 28 | $value = \apcu_fetch($cacheKey); 29 | return $value === false ? null : $value; 30 | } 31 | 32 | public function putCachedString(string $cacheKey, ?string $data): void 33 | { 34 | \apcu_store($cacheKey, $data, $this->_expiration); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Impl/Integrations/CurlEventPublisher.php: -------------------------------------------------------------------------------- 1 | */ 29 | private array $_eventHeaders; 30 | 31 | public function __construct(string $sdkKey, array $options = []) 32 | { 33 | $baseUri = $options['events_uri'] ?? null; 34 | if (!$baseUri) { 35 | $baseUri = LDClient::DEFAULT_EVENTS_URI; 36 | } 37 | $eventsUri = \LaunchDarkly\Impl\Util::adjustBaseUri($baseUri); 38 | 39 | $url = parse_url(rtrim($eventsUri, '/')); 40 | $this->_host = $url['host'] ?? ''; 41 | $this->_ssl = ($url['scheme'] ?? '') === 'https'; 42 | if (isset($url['port'])) { 43 | $this->_port = $url['port']; 44 | } else { 45 | $this->_port = $this->_ssl ? 443 : 80; 46 | } 47 | $this->_path = $url['path'] ?? ''; 48 | 49 | if (array_key_exists('curl', $options)) { 50 | $this->_curl = $options['curl']; 51 | } 52 | 53 | $this->_eventHeaders = Util::eventHeaders($sdkKey, $options); 54 | $this->_connectTimeout = $options['connect_timeout']; 55 | $this->_timeout = $options['timeout']; 56 | $this->_isWindows = PHP_OS_FAMILY == 'Windows'; 57 | } 58 | 59 | public function publish(string $payload): bool 60 | { 61 | if (!$this->_isWindows) { 62 | $args = $this->createCurlArgs($payload); 63 | return $this->makeCurlRequest($args); 64 | } 65 | 66 | $tmpfile = tempnam(sys_get_temp_dir(), 'ld-'); 67 | if ($tmpfile === false) { 68 | return false; 69 | } 70 | 71 | if (file_put_contents($tmpfile, $payload) === false) { 72 | return false; 73 | }; 74 | 75 | $args = $this->createPowershellArgs($tmpfile); 76 | $this->makePowershellRequest($args); 77 | 78 | return true; 79 | } 80 | 81 | private function createCurlArgs(string $payload): string 82 | { 83 | $scheme = $this->_ssl ? "https://" : "http://"; 84 | $args = " -X POST"; 85 | $args.= " --connect-timeout " . $this->_connectTimeout; 86 | $args.= " --max-time " . $this->_timeout; 87 | 88 | foreach ($this->_eventHeaders as $key => $value) { 89 | if ($key == 'Authorization') { 90 | $args.= " -H " . escapeshellarg("Authorization: " . $value); 91 | } else { 92 | $args.= " -H '$key: $value'"; 93 | } 94 | } 95 | 96 | $args.= " -d " . escapeshellarg($payload); 97 | $args.= " " . escapeshellarg($scheme . $this->_host . ":" . $this->_port . $this->_path . "/bulk"); 98 | return $args; 99 | } 100 | 101 | /** 102 | * @psalm-suppress ForbiddenCode 103 | */ 104 | private function makeCurlRequest(string $args): bool 105 | { 106 | $cmd = $this->_curl . " " . $args . ">> /dev/null 2>&1 &"; 107 | shell_exec($cmd); 108 | return true; 109 | } 110 | 111 | private function createPowershellArgs(string $payloadFile): string 112 | { 113 | $headerString = ""; 114 | foreach ($this->_eventHeaders as $key => $value) { 115 | $headerString .= sprintf("'%s'='%s';", $key, $value); 116 | } 117 | 118 | $scheme = $this->_ssl ? "https://" : "http://"; 119 | $args = " Invoke-WebRequest"; 120 | $args.= " -Method POST"; 121 | $args.= " -UseBasicParsing"; 122 | $args.= " -InFile $payloadFile"; 123 | $args.= " -H @{" . $headerString . "}"; 124 | $args.= " -Uri " . escapeshellarg($scheme . $this->_host . ":" . $this->_port . $this->_path . "/bulk"); 125 | $args.= " ; Remove-Item $payloadFile"; 126 | 127 | return $args; 128 | } 129 | 130 | /** 131 | * @psalm-suppress ForbiddenCode 132 | */ 133 | private function makePowershellRequest(string $args): bool 134 | { 135 | $cmd = base64_encode(iconv('ISO-8859-1', 'UTF-16LE', $args)); 136 | shell_exec("start /B powershell.exe -encodedCommand $cmd > nul 2>&1"); 137 | 138 | return true; 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Impl/Integrations/FeatureRequesterBase.php: -------------------------------------------------------------------------------- 1 | _baseUri = $baseUri; 33 | $this->_sdkKey = $sdkKey; 34 | $this->_options = $options; 35 | $this->_cache = $this->createCache($options); 36 | 37 | if ($options['logger'] ?? null) { 38 | $this->_logger = $options['logger']; 39 | } else { 40 | $this->_logger = new NullLogger(); 41 | } 42 | } 43 | 44 | /** 45 | * Override this method to read a JSON object (as a string) from the underlying store. 46 | * 47 | * @param string $namespace "features" or "segments" 48 | * @param string $key flag or segment key 49 | * @return string|null the stored JSON data, or null if not found 50 | */ 51 | protected function readItemString(string $namespace, string $key): ?string 52 | { 53 | return null; 54 | } 55 | 56 | /** 57 | * Override this method to read a set of JSON objects (as strings) from the underlying store. 58 | * 59 | * @param string $namespace "features" or "segments" 60 | * @return array|null array of stored JSON strings 61 | */ 62 | protected function readItemStringList(string $namespace): ?array 63 | { 64 | return []; 65 | } 66 | 67 | /** 68 | * Determines the caching implementation to use, if any. 69 | * 70 | * @return FeatureRequesterCache a cache implementation, or null 71 | */ 72 | protected function createCache(array $options): ?FeatureRequesterCache 73 | { 74 | $expiration = (int)($options['apc_expiration'] ?? 0); 75 | return ($expiration > 0) ? new ApcuFeatureRequesterCache($expiration) : null; 76 | } 77 | 78 | /** 79 | * Gets an individual feature flag. 80 | * 81 | * @param string $key feature flag key 82 | * @return FeatureFlag|null The decoded JSON feature data, or null if missing 83 | */ 84 | public function getFeature(string $key): ?FeatureFlag 85 | { 86 | $json = $this->getJsonItem(self::FEATURES_NAMESPACE, $key); 87 | if ($json) { 88 | $flag = FeatureFlag::decode($json); 89 | if ($flag->isDeleted()) { 90 | $this->_logger->warning("FeatureRequester: Attempted to get deleted feature with key: " . $key); 91 | return null; 92 | } 93 | return $flag; 94 | } else { 95 | $this->_logger->warning("FeatureRequester: Attempted to get missing feature with key: " . $key); 96 | return null; 97 | } 98 | } 99 | 100 | /** 101 | * Gets an individual user segment. 102 | * 103 | * @param string $key segment key 104 | * @return Segment|null The decoded JSON segment data, or null if missing 105 | */ 106 | public function getSegment(string $key): ?Segment 107 | { 108 | $json = $this->getJsonItem(self::SEGMENTS_NAMESPACE, $key); 109 | if ($json) { 110 | $segment = Segment::decode($json); 111 | if ($segment->isDeleted()) { 112 | $this->_logger->warning("FeatureRequester: Attempted to get deleted segment with key: " . $key); 113 | return null; 114 | } 115 | return $segment; 116 | } else { 117 | $this->_logger->warning("FeatureRequester: Attempted to get missing segment with key: " . $key); 118 | return null; 119 | } 120 | } 121 | 122 | /** 123 | * Gets all features 124 | * 125 | * @return array|null The decoded FeatureFlags, or null if missing 126 | */ 127 | public function getAllFeatures(): ?array 128 | { 129 | $jsonList = $this->getJsonItemList(self::FEATURES_NAMESPACE); 130 | $itemsOut = []; 131 | foreach ($jsonList as $json) { 132 | $flag = FeatureFlag::decode($json); 133 | if (!$flag->isDeleted()) { 134 | $itemsOut[$flag->getKey()] = $flag; 135 | } 136 | } 137 | return $itemsOut; 138 | } 139 | 140 | protected function getJsonItem(string $namespace, string $key): ?array 141 | { 142 | $cacheKey = $this->makeCacheKey($namespace, $key); 143 | $raw = $this->_cache?->getCachedString($cacheKey); 144 | if ($raw === null) { 145 | $raw = $this->readItemString($namespace, $key); 146 | $this->_cache?->putCachedString($cacheKey, $raw); 147 | } 148 | return ($raw === null) ? null : json_decode($raw, true); 149 | } 150 | 151 | protected function getJsonItemList(string $namespace): array 152 | { 153 | $cacheKey = $this->makeCacheKey($namespace, self::ALL_ITEMS_KEY); 154 | $raw = $this->_cache?->getCachedString($cacheKey); 155 | if ($raw) { 156 | $values = json_decode($raw, true); 157 | } else { 158 | $values = $this->readItemStringList($namespace); 159 | if (!$values) { 160 | $values = []; 161 | } 162 | $this->_cache?->putCachedString($cacheKey, json_encode($values)); 163 | } 164 | foreach ($values as $i => $s) { 165 | $values[$i] = json_decode($s, true); 166 | } 167 | return $values; 168 | } 169 | 170 | private function makeCacheKey(string $namespace, string $key): string 171 | { 172 | return self::CACHE_PREFIX . $namespace . ':' . $key; 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Impl/Integrations/FeatureRequesterCache.php: -------------------------------------------------------------------------------- 1 | _filePaths = is_array($filePaths) ? $filePaths : [$filePaths]; 27 | $this->_flags = []; 28 | $this->_segments = []; 29 | $this->readAllData(); 30 | } 31 | 32 | /** 33 | * Gets an individual feature flag 34 | * 35 | * @param $key string feature key 36 | * @return FeatureFlag|null The decoded FeatureFlag, or null if missing 37 | */ 38 | public function getFeature(string $key): ?FeatureFlag 39 | { 40 | return $this->_flags[$key] ?? null; 41 | } 42 | 43 | /** 44 | * Gets an individual user segment 45 | * 46 | * @param $key string segment key 47 | * @return Segment|null The decoded Segment, or null if missing 48 | */ 49 | public function getSegment(string $key): ?Segment 50 | { 51 | return $this->_segments[$key] ?? null; 52 | } 53 | 54 | /** 55 | * Gets all feature flags 56 | * 57 | * @return array|null The decoded FeatureFlags, or null if missing 58 | */ 59 | public function getAllFeatures(): ?array 60 | { 61 | return $this->_flags; 62 | } 63 | 64 | private function readAllData(): void 65 | { 66 | $flags = []; 67 | $segments = []; 68 | foreach ($this->_filePaths as $filePath) { 69 | $this->loadFile($filePath, $flags, $segments); 70 | } 71 | $this->_flags = $flags; 72 | $this->_segments = $segments; 73 | } 74 | 75 | private function loadFile(string $filePath, array &$flags, array &$segments): void 76 | { 77 | $content = file_get_contents($filePath); 78 | $data = json_decode($content, true); 79 | if ($data == null) { 80 | throw new \InvalidArgumentException("File is not valid JSON: " . $filePath); 81 | } 82 | foreach ($data['flags'] ?? [] as $key => $value) { 83 | $flag = FeatureFlag::decode($value); 84 | $this->tryToAdd($flags, $key, $flag, "feature flag"); 85 | } 86 | foreach ($data['flagValues'] ?? [] as $key => $value) { 87 | $flag = FeatureFlag::decode([ 88 | "key" => $key, 89 | "version" => 1, 90 | "on" => false, 91 | "prerequisites" => [], 92 | "salt" => "", 93 | "targets" => [], 94 | "rules" => [], 95 | "fallthrough" => [], 96 | "offVariation" => 0, 97 | "variations" => [$value], 98 | "deleted" => false, 99 | "trackEvents" => false, 100 | "clientSide" => false 101 | ]); 102 | $this->tryToAdd($flags, $key, $flag, "feature flag"); 103 | } 104 | foreach ($data['segments'] ?? [] as $key => $value) { 105 | $segment = Segment::decode($value); 106 | $this->tryToAdd($segments, $key, $segment, "user segment"); 107 | } 108 | } 109 | 110 | private function tryToAdd(array &$array, string $key, mixed $item, string $kind): void 111 | { 112 | if (isset($array[$key])) { 113 | throw new \InvalidArgumentException("File data contains more than one " . $kind . " with key: " . $key); 114 | } else { 115 | $array[$key] = $item; 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Impl/Integrations/GuzzleEventPublisher.php: -------------------------------------------------------------------------------- 1 | _sdkKey = $sdkKey; 28 | $this->_logger = $options['logger']; 29 | 30 | $baseUri = $options['events_uri'] ?? null; 31 | if (!$baseUri) { 32 | $baseUri = LDClient::DEFAULT_EVENTS_URI; 33 | } 34 | $this->_eventsUri = \LaunchDarkly\Impl\Util::adjustBaseUri($baseUri); 35 | 36 | $this->_requestOptions = [ 37 | 'headers' => Util::eventHeaders($this->_sdkKey, $options), 38 | 'timeout' => $options['timeout'], 39 | 'connect_timeout' => $options['connect_timeout'] 40 | ]; 41 | } 42 | 43 | public function publish(string $payload): bool 44 | { 45 | $client = new Client(['base_uri' => $this->_eventsUri]); 46 | 47 | try { 48 | $options = $this->_requestOptions; 49 | $options['body'] = $payload; 50 | $response = $client->request('POST', 'bulk', $options); 51 | } catch (\Exception $e) { 52 | $this->_logger->warning("GuzzleEventPublisher::publish caught $e"); 53 | return false; 54 | } 55 | if ($response->getStatusCode() >= 300) { 56 | $this->_logger->error(Util::httpErrorMessage($response->getStatusCode(), 'event posting', 'some events were dropped')); 57 | if (!Util::isHttpErrorRecoverable($response->getStatusCode())) { 58 | throw new UnrecoverableHTTPStatusException($response->getStatusCode()); 59 | } 60 | return false; 61 | } 62 | return true; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Impl/Integrations/GuzzleFeatureRequester.php: -------------------------------------------------------------------------------- 1 | _logger = $options['logger']; 35 | $stack = HandlerStack::create(); 36 | if (class_exists('\Kevinrob\GuzzleCache\CacheMiddleware')) { 37 | $stack->push( 38 | new CacheMiddleware( 39 | new PublicCacheStrategy($options['cache'] ?? null) 40 | ), 41 | 'cache' 42 | ); 43 | } 44 | 45 | $defaults = [ 46 | 'headers' => Util::defaultHeaders($sdkKey, $options), 47 | 'timeout' => $options['timeout'], 48 | 'connect_timeout' => $options['connect_timeout'], 49 | 'handler' => $stack, 50 | 'debug' => $options['debug'] ?? false, 51 | 'base_uri' => $baseUri 52 | ]; 53 | 54 | $this->_client = new Client($defaults); 55 | } 56 | 57 | /** 58 | * Gets feature data from a likely cached store 59 | * 60 | * @param string $key feature key 61 | * @return FeatureFlag|null The decoded FeatureFlag, or null if missing 62 | */ 63 | public function getFeature(string $key): ?FeatureFlag 64 | { 65 | try { 66 | $response = $this->_client->get(self::SDK_FLAGS . "/" . $key); 67 | $body = $response->getBody(); 68 | return FeatureFlag::decode(json_decode($body->getContents(), true)); 69 | } catch (BadResponseException $e) { 70 | /** @psalm-suppress PossiblyNullReference (resolved in guzzle 7) */ 71 | $code = $e->getResponse()->getStatusCode(); 72 | if ($code == 404) { 73 | $this->_logger->warning("GuzzleFeatureRequester::get returned 404. Feature flag does not exist for key: " . $key); 74 | } else { 75 | $this->handleUnexpectedStatus($code, "GuzzleFeatureRequester::get"); 76 | } 77 | return null; 78 | } 79 | } 80 | 81 | /** 82 | * Gets segment data from a likely cached store 83 | * 84 | * @param string $key segment key 85 | * @return Segment|null The decoded Segment, or null if missing 86 | */ 87 | public function getSegment(string $key): ?Segment 88 | { 89 | try { 90 | $response = $this->_client->get(self::SDK_SEGMENTS . "/" . $key); 91 | $body = $response->getBody(); 92 | return Segment::decode(json_decode($body->getContents(), true)); 93 | } catch (BadResponseException $e) { 94 | /** @psalm-suppress PossiblyNullReference (resolved in guzzle 7) */ 95 | $code = $e->getResponse()->getStatusCode(); 96 | if ($code == 404) { 97 | $this->_logger->warning("GuzzleFeatureRequester::get returned 404. Segment does not exist for key: " . $key); 98 | } else { 99 | $this->handleUnexpectedStatus($code, "GuzzleFeatureRequester::get"); 100 | } 101 | return null; 102 | } 103 | } 104 | 105 | /** 106 | * Gets all features from a likely cached store 107 | * 108 | * @return array|null The decoded FeatureFlags, or null if missing 109 | */ 110 | public function getAllFeatures(): ?array 111 | { 112 | try { 113 | $response = $this->_client->get(self::SDK_FLAGS); 114 | $body = $response->getBody(); 115 | return array_map(FeatureFlag::getDecoder(), json_decode($body->getContents(), true)); 116 | } catch (BadResponseException $e) { 117 | /** @psalm-suppress PossiblyNullReference (resolved in guzzle 7) */ 118 | $this->handleUnexpectedStatus($e->getResponse()->getStatusCode(), "GuzzleFeatureRequester::getAll"); 119 | return null; 120 | } 121 | } 122 | 123 | private function handleUnexpectedStatus(int $code, string $method): void 124 | { 125 | $this->_logger->error(Util::httpErrorMessage($code, $method, 'default value was returned')); 126 | if (!Util::isHttpErrorRecoverable($code)) { 127 | throw new UnrecoverableHTTPStatusException($code); 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Impl/Migrations/Executor.php: -------------------------------------------------------------------------------- 1 | fn)($this->payload); 40 | } catch (Exception $e) { 41 | $result = Result::error($e->getMessage(), $e); 42 | } 43 | 44 | if ($this->trackLatency) { 45 | $this->tracker->latency($this->origin, Util::currentTimeUnixMillis() - $start); 46 | } 47 | 48 | if ($this->trackErrors && !$result->isSuccessful()) { 49 | $this->tracker->error($this->origin); 50 | } 51 | 52 | $this->tracker->invoked($this->origin); 53 | 54 | return new OperationResult($this->origin, $result); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Impl/Model/Clause.php: -------------------------------------------------------------------------------- 1 | _contextKind = $contextKind; 26 | $this->_attribute = $attribute; 27 | $this->_op = $op; 28 | $this->_values = $values; 29 | $this->_negate = $negate; 30 | } 31 | 32 | /** 33 | * @psalm-return \Closure(mixed):self 34 | */ 35 | public static function getDecoder(): \Closure 36 | { 37 | return fn ($v) => new Clause($v['contextKind'] ?? null, $v['attribute'], $v['op'], $v['values'], $v['negate']); 38 | } 39 | 40 | public function getAttribute(): ?string 41 | { 42 | return $this->_attribute; 43 | } 44 | 45 | public function getContextKind(): ?string 46 | { 47 | return $this->_contextKind; 48 | } 49 | 50 | public function getOp(): ?string 51 | { 52 | return $this->_op; 53 | } 54 | 55 | public function getValues(): array 56 | { 57 | return $this->_values; 58 | } 59 | 60 | public function isNegate(): bool 61 | { 62 | return $this->_negate; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Impl/Model/FeatureFlag.php: -------------------------------------------------------------------------------- 1 | _key = $key; 67 | $this->_version = $version; 68 | $this->_on = $on; 69 | $this->_prerequisites = $prerequisites; 70 | $this->_salt = $salt; 71 | $this->_targets = $targets; 72 | $this->_contextTargets = $contextTargets; 73 | $this->_rules = $rules; 74 | $this->_fallthrough = $fallthrough; 75 | $this->_offVariation = $offVariation; 76 | $this->_variations = $variations; 77 | $this->_deleted = $deleted; 78 | $this->_trackEvents = $trackEvents; 79 | $this->_trackEventsFallthrough = $trackEventsFallthrough; 80 | $this->_debugEventsUntilDate = $debugEventsUntilDate; 81 | $this->_clientSide = $clientSide; 82 | $this->_samplingRatio = $samplingRatio; 83 | $this->_excludeFromSummaries = $excludeFromSummaries; 84 | $this->_migrationSettings = $migrationSettings; 85 | } 86 | 87 | /** 88 | * @return \Closure 89 | * 90 | * @psalm-return \Closure(mixed):self 91 | */ 92 | public static function getDecoder(): \Closure 93 | { 94 | return function ($v) { 95 | $migrationSettings = null; 96 | 97 | if (is_array($v['migration'] ?? null)) { 98 | $migrationSettings = call_user_func(MigrationSettings::getDecoder(), $v['migration']); 99 | } 100 | 101 | return new FeatureFlag( 102 | $v['key'], 103 | $v['version'], 104 | $v['on'], 105 | array_map(Prerequisite::getDecoder(), $v['prerequisites'] ?: []), 106 | $v['salt'], 107 | array_map(Target::getDecoder(), $v['targets'] ?: []), 108 | array_map(Target::getDecoder(), $v['contextTargets'] ?? []), 109 | array_map(Rule::getDecoder(), $v['rules'] ?: []), 110 | call_user_func(VariationOrRollout::getDecoder(), $v['fallthrough']), 111 | $v['offVariation'], 112 | $v['variations'] ?: [], 113 | $v['deleted'], 114 | !!($v['trackEvents'] ?? false), 115 | !!($v['trackEventsFallthrough'] ?? false), 116 | $v['debugEventsUntilDate'] ?? null, 117 | !!($v['clientSide'] ?? false), 118 | $v['samplingRatio'] ?? null, 119 | !!($v['excludeFromSummaries'] ?? false), 120 | $migrationSettings, 121 | ); 122 | }; 123 | } 124 | 125 | public static function decode(array $v): self 126 | { 127 | $decoder = FeatureFlag::getDecoder(); 128 | return $decoder($v); 129 | } 130 | 131 | public function isClientSide(): bool 132 | { 133 | return $this->_clientSide; 134 | } 135 | 136 | /** @return Target[] */ 137 | public function getContextTargets(): array 138 | { 139 | return $this->_contextTargets; 140 | } 141 | 142 | public function getDebugEventsUntilDate(): ?int 143 | { 144 | return $this->_debugEventsUntilDate; 145 | } 146 | 147 | public function isDeleted(): bool 148 | { 149 | return $this->_deleted; 150 | } 151 | 152 | public function getFallthrough(): VariationOrRollout 153 | { 154 | return $this->_fallthrough; 155 | } 156 | 157 | public function getKey(): string 158 | { 159 | return $this->_key; 160 | } 161 | 162 | public function getOffVariation(): ?int 163 | { 164 | return $this->_offVariation; 165 | } 166 | 167 | public function isOn(): bool 168 | { 169 | return $this->_on; 170 | } 171 | 172 | /** @return Prerequisite[] */ 173 | public function getPrerequisites(): array 174 | { 175 | return $this->_prerequisites; 176 | } 177 | 178 | /** @return Rule[] */ 179 | public function getRules(): array 180 | { 181 | return $this->_rules; 182 | } 183 | 184 | public function getSalt(): string 185 | { 186 | return $this->_salt; 187 | } 188 | 189 | /** @return Target[] */ 190 | public function getTargets(): array 191 | { 192 | return $this->_targets; 193 | } 194 | 195 | public function isTrackEvents(): bool 196 | { 197 | return $this->_trackEvents; 198 | } 199 | 200 | public function isTrackEventsFallthrough(): bool 201 | { 202 | return $this->_trackEventsFallthrough; 203 | } 204 | 205 | public function getVariations(): array 206 | { 207 | return $this->_variations; 208 | } 209 | 210 | public function getVersion(): int 211 | { 212 | return $this->_version; 213 | } 214 | 215 | public function getSamplingRatio(): int 216 | { 217 | return $this->_samplingRatio ?? 1; 218 | } 219 | 220 | public function getExcludeFromSummaries(): bool 221 | { 222 | return $this->_excludeFromSummaries; 223 | } 224 | 225 | public function getMigrationSettings(): ?MigrationSettings 226 | { 227 | return $this->_migrationSettings; 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Impl/Model/MigrationSettings.php: -------------------------------------------------------------------------------- 1 | checkRatio ?? 1; 24 | } 25 | 26 | public static function getDecoder(): \Closure 27 | { 28 | return fn (array $v) => new MigrationSettings($v['checkRatio'] ?? null); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Impl/Model/Prerequisite.php: -------------------------------------------------------------------------------- 1 | _key = $key; 23 | $this->_variation = $variation; 24 | } 25 | 26 | public static function getDecoder(): \Closure 27 | { 28 | return fn (array $v) => new Prerequisite($v['key'], $v['variation']); 29 | } 30 | 31 | public function getKey(): string 32 | { 33 | return $this->_key; 34 | } 35 | 36 | public function getVariation(): int 37 | { 38 | return $this->_variation; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Impl/Model/Rollout.php: -------------------------------------------------------------------------------- 1 | _variations = $variations; 34 | $this->_bucketBy = $bucketBy; 35 | $this->_kind = $kind ?: 'rollout'; 36 | $this->_seed = $seed; 37 | $this->_contextKind = $contextKind; 38 | } 39 | 40 | /** 41 | * @psalm-return \Closure(array):self 42 | */ 43 | public static function getDecoder(): \Closure 44 | { 45 | return function (array $v) { 46 | $decoder = WeightedVariation::getDecoder(); 47 | $vars = array_map($decoder, $v['variations']); 48 | $bucket = $v['bucketBy'] ?? null; 49 | 50 | return new Rollout($vars, $bucket, $v['kind'] ?? null, $v['seed'] ?? null, $v['contextKind'] ?? null); 51 | }; 52 | } 53 | 54 | /** 55 | * @return WeightedVariation[] 56 | */ 57 | public function getVariations(): array 58 | { 59 | return $this->_variations; 60 | } 61 | 62 | public function getBucketBy(): ?string 63 | { 64 | return $this->_bucketBy; 65 | } 66 | 67 | public function getSeed(): ?int 68 | { 69 | return $this->_seed; 70 | } 71 | 72 | public function isExperiment(): bool 73 | { 74 | return $this->_kind === self::KIND_EXPERIMENT; 75 | } 76 | 77 | public function getContextKind(): ?string 78 | { 79 | return $this->_contextKind; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Impl/Model/Rule.php: -------------------------------------------------------------------------------- 1 | _id = $id; 31 | $this->_clauses = $clauses; 32 | $this->_trackEvents = $trackEvents; 33 | } 34 | 35 | public static function getDecoder(): \Closure 36 | { 37 | return fn (array $v) => 38 | new Rule( 39 | $v['variation'] ?? null, 40 | isset($v['rollout']) ? call_user_func(Rollout::getDecoder(), $v['rollout']) : null, 41 | $v['id'] ?? null, 42 | array_map(Clause::getDecoder(), $v['clauses']), 43 | !!($v['trackEvents'] ?? false) 44 | ); 45 | } 46 | 47 | public function getId(): ?string 48 | { 49 | return $this->_id; 50 | } 51 | 52 | /** 53 | * @return Clause[] 54 | */ 55 | public function getClauses(): array 56 | { 57 | return $this->_clauses; 58 | } 59 | 60 | public function isTrackEvents(): bool 61 | { 62 | return $this->_trackEvents; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Impl/Model/Segment.php: -------------------------------------------------------------------------------- 1 | _key = $key; 52 | $this->_version = $version; 53 | $this->_included = $included; 54 | $this->_excluded = $excluded; 55 | $this->_includedContexts = $includedContexts; 56 | $this->_excludedContexts = $excludedContexts; 57 | $this->_unbounded = $unbounded; 58 | $this->_unboundedContextKind = $unboundedContextKind ?? LDContext::DEFAULT_KIND; 59 | $this->_generation = $generation; 60 | $this->_salt = $salt; 61 | $this->_rules = $rules; 62 | $this->_deleted = $deleted; 63 | } 64 | 65 | public static function getDecoder(): \Closure 66 | { 67 | return fn (array $v) => 68 | new Segment( 69 | $v['key'], 70 | $v['version'], 71 | $v['included'] ?: [], 72 | $v['excluded'] ?: [], 73 | array_map(SegmentTarget::getDecoder(), $v['includedContexts'] ?? []), 74 | array_map(SegmentTarget::getDecoder(), $v['excludedContexts'] ?? []), 75 | $v['unbounded'] ?? false, 76 | $v['unboundedContextKind'] ?? null, 77 | $v['generation'] ?? null, 78 | $v['salt'], 79 | array_map(SegmentRule::getDecoder(), $v['rules'] ?: []), 80 | $v['deleted'] 81 | ); 82 | } 83 | 84 | public static function decode(array $v): Segment 85 | { 86 | return static::getDecoder()($v); 87 | } 88 | 89 | public function isDeleted(): bool 90 | { 91 | return $this->_deleted; 92 | } 93 | 94 | /** @return string[] */ 95 | public function getExcluded(): array 96 | { 97 | return $this->_excluded; 98 | } 99 | 100 | /** @return SegmentTarget[] */ 101 | public function getExcludedContexts(): array 102 | { 103 | return $this->_excludedContexts; 104 | } 105 | 106 | /** @return string[] */ 107 | public function getIncluded(): array 108 | { 109 | return $this->_included; 110 | } 111 | 112 | /** @return SegmentTarget[] */ 113 | public function getIncludedContexts(): array 114 | { 115 | return $this->_includedContexts; 116 | } 117 | 118 | public function getUnbounded(): bool 119 | { 120 | return $this->_unbounded; 121 | } 122 | 123 | public function getUnboundedContextKind(): string 124 | { 125 | return $this->_unboundedContextKind; 126 | } 127 | 128 | public function getGeneration(): ?int 129 | { 130 | return $this->_generation; 131 | } 132 | 133 | public function getKey(): string 134 | { 135 | return $this->_key; 136 | } 137 | 138 | /** @return SegmentRule[] */ 139 | public function getRules(): array 140 | { 141 | return $this->_rules; 142 | } 143 | 144 | public function getSalt(): string 145 | { 146 | return $this->_salt; 147 | } 148 | 149 | public function getVersion(): ?int 150 | { 151 | return $this->_version; 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Impl/Model/SegmentRule.php: -------------------------------------------------------------------------------- 1 | _clauses = $clauses; 26 | $this->_weight = $weight; 27 | $this->_bucketBy = $bucketBy; 28 | $this->_rolloutContextKind = $rolloutContextKind; 29 | } 30 | 31 | public static function getDecoder(): \Closure 32 | { 33 | return fn (array $v) => new SegmentRule( 34 | array_map(Clause::getDecoder(), $v['clauses'] ?: []), 35 | $v['weight'] ?? null, 36 | $v['bucketBy'] ?? null, 37 | $v['rolloutContextKind'] ?? null 38 | ); 39 | } 40 | 41 | /** 42 | * @return Clause[] 43 | */ 44 | public function getClauses(): array 45 | { 46 | return $this->_clauses; 47 | } 48 | 49 | public function getBucketBy(): ?string 50 | { 51 | return $this->_bucketBy; 52 | } 53 | 54 | public function getRolloutContextKind(): ?string 55 | { 56 | return $this->_rolloutContextKind; 57 | } 58 | 59 | public function getWeight(): ?int 60 | { 61 | return $this->_weight; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Impl/Model/SegmentTarget.php: -------------------------------------------------------------------------------- 1 | _contextKind = $contextKind; 24 | $this->_values = $values; 25 | } 26 | 27 | public static function getDecoder(): \Closure 28 | { 29 | return fn (array $v) => new SegmentTarget($v['contextKind'] ?? null, $v['values']); 30 | } 31 | 32 | public function getContextKind(): ?string 33 | { 34 | return $this->_contextKind; 35 | } 36 | 37 | /** 38 | * @return \string[] 39 | */ 40 | public function getValues(): array 41 | { 42 | return $this->_values; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Impl/Model/Target.php: -------------------------------------------------------------------------------- 1 | _contextKind = $contextKind; 25 | $this->_values = $values; 26 | $this->_variation = $variation; 27 | } 28 | 29 | public static function getDecoder(): \Closure 30 | { 31 | return fn (array $v) => new Target($v['contextKind'] ?? null, $v['values'], $v['variation']); 32 | } 33 | 34 | public function getContextKind(): ?string 35 | { 36 | return $this->_contextKind; 37 | } 38 | 39 | /** 40 | * @return \string[] 41 | */ 42 | public function getValues(): array 43 | { 44 | return $this->_values; 45 | } 46 | 47 | public function getVariation(): int 48 | { 49 | return $this->_variation; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Impl/Model/VariationOrRollout.php: -------------------------------------------------------------------------------- 1 | _variation = $variation; 23 | $this->_rollout = $rollout; 24 | } 25 | 26 | /** 27 | * @psalm-return \Closure(array):self 28 | */ 29 | public static function getDecoder(): \Closure 30 | { 31 | return function (?array $v) { 32 | $decoder = Rollout::getDecoder(); 33 | $variation = $v['variation'] ?? null; 34 | $rollout = isset($v['rollout']) ? $decoder($v['rollout']) : null; 35 | 36 | return new VariationOrRollout($variation, $rollout); 37 | }; 38 | } 39 | 40 | public function getVariation(): ?int 41 | { 42 | return $this->_variation; 43 | } 44 | 45 | public function getRollout(): ?Rollout 46 | { 47 | return $this->_rollout; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Impl/Model/WeightedVariation.php: -------------------------------------------------------------------------------- 1 | _variation = $variation; 24 | $this->_weight = $weight; 25 | $this->_untracked = $untracked; 26 | } 27 | 28 | /** 29 | * @psalm-return \Closure(array):self 30 | */ 31 | public static function getDecoder(): \Closure 32 | { 33 | return fn (array $v) => new WeightedVariation( 34 | (int)$v['variation'], 35 | (int)$v['weight'], 36 | $v['untracked'] ?? false 37 | ); 38 | } 39 | 40 | public function getVariation(): int 41 | { 42 | return $this->_variation; 43 | } 44 | 45 | public function getWeight(): int 46 | { 47 | return $this->_weight; 48 | } 49 | 50 | public function isUntracked(): bool 51 | { 52 | return $this->_untracked; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Impl/PreloadedFeatureRequester.php: -------------------------------------------------------------------------------- 1 | _baseRequester = $baseRequester; 25 | $this->_knownFeatures = $knownFeatures; 26 | } 27 | 28 | /** 29 | * Gets feature data from cached values 30 | * 31 | * @param string $key feature key 32 | * @return FeatureFlag|null The decoded FeatureFlag, or null if missing 33 | */ 34 | public function getFeature(string $key): ?FeatureFlag 35 | { 36 | return $this->_knownFeatures[$key] ?? null; 37 | } 38 | 39 | /** 40 | * Gets segment data from the regular feature requester 41 | * 42 | * @param string $key segment key 43 | * @return Segment|null The decoded Segment, or null if missing 44 | */ 45 | public function getSegment(string $key): ?Segment 46 | { 47 | return $this->_baseRequester->getSegment($key); 48 | } 49 | 50 | /** 51 | * Gets all features from cached values 52 | * 53 | * @return array|null The decoded FeatureFlags, or null if missing 54 | */ 55 | public function getAllFeatures(): ?array 56 | { 57 | return $this->_knownFeatures; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Impl/SemanticVersion.php: -------------------------------------------------------------------------------- 1 | 0|[1-9]\d*)(\.(?0|[1-9]\d*))?(\.(?0|[1-9]\d*))?(\-(?[0-9A-Za-z\-\.]+))?(\+(?[0-9A-Za-z\-\.]+))?$/'; 21 | 22 | public int $major; 23 | public int $minor; 24 | public int $patch; 25 | public string $prerelease; 26 | public string $build; 27 | 28 | public function __construct( 29 | int $major, 30 | int $minor, 31 | int $patch, 32 | string $prerelease, 33 | string $build 34 | ) { 35 | $this->major = $major; 36 | $this->minor = $minor; 37 | $this->patch = $patch; 38 | $this->prerelease = $prerelease; 39 | $this->build = $build; 40 | } 41 | 42 | /** 43 | * Attempts to parse a string as a semantic version. 44 | * @param string $input the input string 45 | * @param bool $loose true if minor and patch versions can be omitted 46 | * @throws \InvalidArgumentException if the string is not in an acceptable format 47 | */ 48 | public static function parse(string $input, bool $loose = false): SemanticVersion 49 | { 50 | if (!preg_match(self::REGEX, $input, $matches)) { 51 | throw new \InvalidArgumentException("not a valid semantic version"); 52 | } 53 | $major = intval($matches['major']); 54 | if (!$loose && (!array_key_exists('minor', $matches) || !array_key_exists('patch', $matches))) { 55 | throw new \InvalidArgumentException("not a valid semantic version: minor and patch versions are required"); 56 | } 57 | $minor = array_key_exists('minor', $matches) ? intval($matches['minor']) : 0; 58 | $patch = array_key_exists('patch', $matches) ? intval($matches['patch']) : 0; 59 | $prerelease = array_key_exists('prerel', $matches) ? $matches['prerel'] : ''; 60 | $build = array_key_exists('build', $matches) ? $matches['build'] : ''; 61 | return new SemanticVersion($major, $minor, $patch, $prerelease, $build); 62 | } 63 | 64 | /** 65 | * Compares this version to another version using Semantic Versioning precedence rules. 66 | * @param SemanticVersion $other a SemanticVersion object 67 | * @return int -1 if this version has lower precedence than the other version; 1 if this version 68 | * has higher precedence; zero if the two have equal precedence 69 | */ 70 | public function comparePrecedence(SemanticVersion $other): int 71 | { 72 | if ($this->major != $other->major) { 73 | return ($this->major < $other->major) ? -1 : 1; 74 | } 75 | if ($this->minor != $other->minor) { 76 | return ($this->minor < $other->minor) ? -1 : 1; 77 | } 78 | if ($this->patch != $other->patch) { 79 | return ($this->patch < $other->patch) ? -1 : 1; 80 | } 81 | if ($this->prerelease != $other->prerelease) { 82 | // *no* prerelease component always has a higher precedence than *any* prerelease component 83 | if ($this->prerelease == '') { 84 | return 1; 85 | } 86 | if ($other->prerelease == '') { 87 | return -1; 88 | } 89 | return self::compareIdentifiers(explode('.', $this->prerelease), explode('.', $other->prerelease)); 90 | } 91 | // build metadata is always ignored in precedence comparison 92 | return 0; 93 | } 94 | 95 | /** 96 | * @param array $ids1 97 | * @param array $ids2 98 | */ 99 | private static function compareIdentifiers(array $ids1, array $ids2): int 100 | { 101 | $result = 0; 102 | for ($i = 0; ; $i++) { 103 | if ($i >= count($ids1)) { 104 | // x.y is always less than x.y.z 105 | $result = ($i >= count($ids2)) ? 0 : -1; 106 | break; 107 | } 108 | if ($i >= count($ids2)) { 109 | $result = 1; 110 | break; 111 | } 112 | $v1 = $ids1[$i]; 113 | $v2 = $ids2[$i]; 114 | // each sub-identifier is compared numerically if both are numeric; if both are non-numeric, 115 | // they're compared as strings; otherwise, the numeric one is the lesser one 116 | $isNum1 = is_numeric($v1); 117 | $isNum2 = is_numeric($v2); 118 | if ($isNum1 && $isNum2) { 119 | $n1 = intval($v1); 120 | $n2 = intval($v2); 121 | $d = ($n1 == $n2) ? 0 : (($n1 < $n2) ? -1 : 1); 122 | } else { 123 | if ($isNum1 || $isNum2) { 124 | $d = $isNum1 ? -1 : 1; 125 | } else { 126 | $d = ($v1 == $v2) ? 0 : (($v1 < $v2) ? -1 : 1); 127 | } 128 | } 129 | if ($d != 0) { 130 | $result = $d; 131 | break; 132 | } 133 | } 134 | return $result; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Impl/UnrecoverableHTTPStatusException.php: -------------------------------------------------------------------------------- 1 | status = $status; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Impl/Util.php: -------------------------------------------------------------------------------- 1 | getTimestamp(); 50 | $timestampMicros = (int)$dateTime->format('u'); 51 | return $timeStampSeconds * 1000 + (int)($timestampMicros / 1000); 52 | } 53 | 54 | public static function currentTimeUnixMillis(): int 55 | { 56 | return Util::dateTimeToUnixMillis(new DateTime('now', new DateTimeZone("UTC"))); 57 | } 58 | 59 | public static function isHttpErrorRecoverable(int $status): bool 60 | { 61 | if ($status >= 400 && $status < 500) { 62 | return ($status == 400) || ($status == 408) || ($status == 429); 63 | } 64 | return true; 65 | } 66 | 67 | public static function httpErrorMessage(int $status, string $context, string $retryMessage): string 68 | { 69 | return 'Received error ' . $status 70 | . (($status == 401) ? ' (invalid SDK key)' : '') 71 | . ' for ' . $context . ' - ' 72 | . (Util::isHttpErrorRecoverable($status) ? $retryMessage : 'giving up permanently'); 73 | } 74 | 75 | public static function logExceptionAtErrorLevel(LoggerInterface $logger, \Throwable $e, string $message): void 76 | { 77 | $logger->error( 78 | $message . ': ' . $e->getMessage(), 79 | [ 80 | 'exception' => $e, 81 | ] 82 | ); 83 | } 84 | 85 | public static function makeNullLogger(): LoggerInterface 86 | { 87 | return new Logger('', [new NullHandler()]); 88 | } 89 | 90 | /** 91 | * An array of header name and values that should be used for any request 92 | * made to LaunchDarkly servers. 93 | * 94 | * @param string $sdkKey 95 | * @params array $options 96 | * @return array 97 | */ 98 | public static function defaultHeaders(string $sdkKey, array $options): array 99 | { 100 | $headers = [ 101 | 'Content-Type' => 'application/json', 102 | 'Accept' => 'application/json', 103 | 'Authorization' => $sdkKey, 104 | 'User-Agent' => 'PHPClient/' . LDClient::VERSION, 105 | ]; 106 | 107 | $applicationInfo = $options['application_info'] ?? null; 108 | if ($applicationInfo instanceof ApplicationInfo) { 109 | $headerValue = (string) $applicationInfo; 110 | if ($headerValue) { 111 | $headers['X-LaunchDarkly-Tags'] = $headerValue; 112 | } 113 | } 114 | 115 | if (!empty($options['wrapper_name'])) { 116 | $headers['X-LaunchDarkly-Wrapper'] = $options['wrapper_name']; 117 | 118 | if (!empty($options['wrapper_version'])) { 119 | $headers['X-LaunchDarkly-Wrapper'] .= '/' . $options['wrapper_version']; 120 | } 121 | } 122 | 123 | if (!empty($options['instance_id'])) { 124 | $headers['X-LaunchDarkly-Instance-Id'] = $options['instance_id']; 125 | } 126 | 127 | return $headers; 128 | } 129 | 130 | /** 131 | * An array of header name and values that should be used for any request 132 | * made to the LaunchDarkly Events API. 133 | * 134 | * @param string $sdkKey 135 | * @param array $options 136 | * @return array 137 | */ 138 | public static function eventHeaders(string $sdkKey, array $options): array 139 | { 140 | $headers = Util::defaultHeaders($sdkKey, $options); 141 | $headers['X-LaunchDarkly-Event-Schema'] = EventPublisher::CURRENT_SCHEMA_VERSION; 142 | // Only the presence of this header is important. We encode a string 143 | // value of 'true' to ensure it isn't dropped along the way. 144 | $headers['X-LaunchDarkly-Unsummarized'] = 'true'; 145 | 146 | return $headers; 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Integrations/Curl.php: -------------------------------------------------------------------------------- 1 | $ep ]; 23 | * $client = new LDClient("sdk_key", $config); 24 | * 25 | * This implementation forks a process for each event payload. Alternatively, you can use 26 | * {@see \LaunchDarkly\Integrations\Guzzle::eventPublisher()}, which makes synchronous requests. 27 | * 28 | * @param array $options Configuration settings (can also be passed in the main client configuration): 29 | * - `curl`: command for executing `curl`; defaults to `/usr/bin/env curl` 30 | * @return mixed an object to be stored in the `event_publisher` configuration property 31 | */ 32 | public static function eventPublisher(array $options = []): mixed 33 | { 34 | return fn (string $sdkKey, array $baseOptions) => 35 | new \LaunchDarkly\Impl\Integrations\CurlEventPublisher( 36 | $sdkKey, 37 | array_merge($baseOptions, $options) 38 | ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Integrations/Files.php: -------------------------------------------------------------------------------- 1 | $fr, "send_events" => false ]; 25 | * $client = new LDClient("sdk_key", $config); 26 | * 27 | * This will cause the client _not_ to connect to LaunchDarkly to get feature flags. (Note 28 | * that in this example, `send_events` is also set to false so that it will not connect to 29 | * LaunchDarkly to send analytics events either.) 30 | * 31 | * For more information about using this component, and the format of data files, see 32 | * the SDK reference guide on ["Reading flags from a file"](https://docs.launchdarkly.com/sdk/features/flags-from-files#php). 33 | * 34 | * @param string|string[] $filePaths relative or absolute paths to the data files 35 | * @return mixed an object to be stored in the `feature_requester` configuration property 36 | */ 37 | public static function featureRequester(string|array $filePaths): mixed 38 | { 39 | return new \LaunchDarkly\Impl\Integrations\FileDataFeatureRequester($filePaths); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Integrations/Guzzle.php: -------------------------------------------------------------------------------- 1 | 30 | new \LaunchDarkly\Impl\Integrations\GuzzleFeatureRequester( 31 | $baseUri, 32 | $sdkKey, 33 | array_merge($baseOptions, $options) 34 | ); 35 | } 36 | 37 | /** 38 | * Configures an adapter for sending analytics events to LaunchDarkly using GuzzleHttp. 39 | * 40 | * The default mechanism for sending events is {@see \LaunchDarkly\Integrations\Curl::eventPublisher()}. 41 | * To use Guzzle instead, call this method and store its return value in the `event_publisher` property 42 | * of the client configuration: 43 | * 44 | * $ep = LaunchDarkly\Integrations\Guzzle::eventPublisher(); 45 | * $config = [ "event_publisher" => $ep ]; 46 | * $client = new LDClient("sdk_key", $config); 47 | * 48 | * Unlike the curl implementation, which forks processes, this implementation executes synchronously in 49 | * the request handler. In order to minimize request overhead, we recommend that you set up `ld-relay` 50 | * in your production environment and configure the `events_uri` option for `LDClient` to publish to 51 | * `ld-relay`. 52 | * 53 | * @param array $options Configuration settings (can also be passed in the main client configuration): 54 | * - `events_uri`: URI of the server that will receive events, if it is `ld-relay` instead of LaunchDarkly 55 | * - `connect_timeout`: connection timeout in seconds; defaults to 3 56 | * - `timeout`: read timeout in seconds; defaults to 3 57 | * @return mixed an object to be stored in the `event_publisher` configuration property 58 | */ 59 | public static function eventPublisher(array $options = []): mixed 60 | { 61 | return fn (string $sdkKey, array $baseOptions) => 62 | new \LaunchDarkly\Impl\Integrations\GuzzleEventPublisher( 63 | $sdkKey, 64 | array_merge($baseOptions, $options) 65 | ); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Integrations/TestData.php: -------------------------------------------------------------------------------- 1 | _flagBuilders = []; 20 | $this->_currentFlags = []; 21 | } 22 | 23 | /** 24 | * Gets the configuration for a specific feature flag. 25 | * 26 | * @param string $key feature key 27 | * @return FeatureFlag|null The decoded FeatureFlag, or null if missing 28 | */ 29 | public function getFeature(string $key): ?FeatureFlag 30 | { 31 | return $this->_currentFlags[$key] ?? null; 32 | } 33 | 34 | /** 35 | * Gets the configuration for a specific user segment. 36 | * 37 | * @param string $key segment key 38 | * @return Segment|null The decoded Segment, or null if missing 39 | */ 40 | public function getSegment(string $key): ?Segment 41 | { 42 | return null; 43 | } 44 | 45 | /** 46 | * Gets all feature flags. 47 | * 48 | * @return array|null The decoded FeatureFlags, or null if missing 49 | */ 50 | public function getAllFeatures(): ?array 51 | { 52 | return $this->_currentFlags; 53 | } 54 | 55 | /** 56 | * Creates a new instance of the test data source 57 | * 58 | * @return TestData a new configurable test data source 59 | */ 60 | public function dataSource(): TestData 61 | { 62 | return new TestData(); 63 | } 64 | 65 | /** 66 | * Creates or copies a `FlagBuilder` for building a test flag configuration. 67 | * 68 | * If this flag key has already been defined in this `TestData` instance, then the builder 69 | * starts with the same configuration that was last provided for this flag. 70 | * 71 | * Otherwise, it starts with a new default configuration in which the flag has `true` and 72 | * `false` variations, is `true` for all users when targeting is turned on and 73 | * `false` otherwise, and currently has targeting turned on. You can change any of those 74 | * properties, and provide more complex behavior, using the `FlagBuilder` methods. 75 | * 76 | * Once you have set the desired configuration, pass the builder to `update`. 77 | * 78 | * @param string $key the flag key 79 | * @return FlagBuilder the flag configuration builder object 80 | */ 81 | public function flag(string $key): FlagBuilder 82 | { 83 | if (isset($this->_flagBuilders[$key])) { 84 | return $this->_flagBuilders[$key]->copy(); 85 | } else { 86 | $flagBuilder = new FlagBuilder($key); 87 | return $flagBuilder->booleanFlag(); 88 | } 89 | } 90 | 91 | /** 92 | * Updates the test data with the specified flag configuration. 93 | * 94 | * This has the same effect as if a flag were added or modified on the LaunchDarkly dashboard. 95 | * It immediately propagates the flag change to any `LDClient` instance(s) that you have 96 | * already configured to use this `TestData`. If no `LDClient` has been started yet, 97 | * it simply adds this flag to the test data which will be provided to any `LDClient` that 98 | * you subsequently configure. 99 | * 100 | * Any subsequent changes to this `FlagBuilder` instance do not affect the test data, 101 | * unless you call `update(FlagBuilder)` again. 102 | * 103 | * @param FlagBuilder $flagBuilder a flag configuration builder 104 | * @return TestData the same `TestData` instance 105 | */ 106 | public function update(FlagBuilder $flagBuilder): TestData 107 | { 108 | $key = $flagBuilder->getKey(); 109 | $oldVersion = 0; 110 | 111 | $oldFlag = $this->_currentFlags[$key] ?? null; 112 | if ($oldFlag) { 113 | $oldVersion = $oldFlag->getVersion(); 114 | } 115 | 116 | $newFlag = $flagBuilder->build($oldVersion + 1); 117 | $newFeatureFlag = FeatureFlag::decode($newFlag); 118 | $this->_currentFlags[$key] = $newFeatureFlag; 119 | $this->_flagBuilders[$key] = $flagBuilder->copy(); 120 | return $this; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Integrations/TestData/FlagRuleBuilder.php: -------------------------------------------------------------------------------- 1 | _flagBuilder = $flagBuilder; 34 | $this->_clauses = []; 35 | $this->_variation = null; 36 | } 37 | 38 | /** 39 | * Adds another clause, using the "is one of" operator. 40 | * 41 | * This is a shortcut for calling {@see \LaunchDarkly\Integrations\TestData\FlagRuleBuilder::andMatchContext()} 42 | * with `LDContext::DEFAULT_KIND` as the context kind. 43 | * 44 | * For example, this creates a rule that returns `true` if the name is "Patsy" and the 45 | * country is "gb": 46 | * 47 | * $testData->flag("flag") 48 | * ->ifMatch("name", "Patsy") 49 | * ->andMatch("country", "gb") 50 | * ->thenReturn(true); 51 | * 52 | * @param string $attribute the user attribute to match against 53 | * @param mixed[] $values values to compare to 54 | * @return FlagRuleBuilder the rule builder 55 | */ 56 | public function andMatch(string $attribute, mixed ...$values) 57 | { 58 | return $this->andMatchContext(LDContext::DEFAULT_KIND, $attribute, ...$values); 59 | } 60 | 61 | /** 62 | * Adds another clause, using the "is one of" operator. This matching expression only 63 | * applies to contexts of a specific kind. 64 | * 65 | * For example, this creates a rule that returns `true` if the name attribute for the 66 | * "company" context is "Ella", and the country attribute for the "company" context is "gb": 67 | * 68 | * $testData->flag("flag") 69 | * ->ifMatchContext("company", "name", "Ella") 70 | * ->andMatchContext("company", "country", "gb") 71 | * ->thenReturn(true); 72 | * 73 | * @param string $attribute the user attribute to match against 74 | * @param mixed[] $values values to compare to 75 | * @return FlagRuleBuilder the rule builder 76 | */ 77 | public function andMatchContext(string $contextKind, string $attribute, mixed ...$values) 78 | { 79 | $newClause = [ 80 | "contextKind" => $contextKind, 81 | "attribute" => $attribute, 82 | "op" => 'in', 83 | "values" => $values, 84 | "negate" => false, 85 | ]; 86 | $this->_clauses[] = $newClause; 87 | return $this; 88 | } 89 | 90 | /** 91 | * Adds another clause, using the "is not one of" operator. 92 | * 93 | * This is a shortcut for calling {@see \LaunchDarkly\Integrations\TestData\FlagRuleBuilder::andNotMatchContext()} 94 | * with`LDContext::DEFAULT_KIND` as the context kind. 95 | * 96 | * For example, this creates a rule that returns `true` if 97 | * the name is "Patsy" and the country is not "gb": 98 | * 99 | * $testData->flag("flag") 100 | * ->ifMatch("name", "Patsy") 101 | * ->andNotMatch("country", "gb") 102 | * ->thenReturn(true); 103 | * 104 | * @param string $attribute the user attribute to match against 105 | * @param mixed[] $values values to compare to 106 | * @return FlagRuleBuilder the rule builder 107 | */ 108 | public function andNotMatch(string $attribute, mixed ...$values) 109 | { 110 | return $this->andNotMatchContext(LDContext::DEFAULT_KIND, $attribute, ...$values); 111 | } 112 | 113 | /** 114 | * Adds another clause, using the "is not one of" operator. This matching expression only 115 | * applies to contexts of a specific kind. 116 | * 117 | * For example, this creates a rule that returns `true` if the name attribute for the 118 | * "company" context is "Ella", and the country attribute for the "company" context is not "gb": 119 | * 120 | * $testData->flag("flag") 121 | * ->ifMatchContext("company", "name", "Ella") 122 | * ->andNotMatchContext("company", "country", "gb") 123 | * ->thenReturn(true); 124 | * 125 | * @param string $attribute the user attribute to match against 126 | * @param mixed[] $values values to compare to 127 | * @return FlagRuleBuilder the rule builder 128 | */ 129 | public function andNotMatchContext(string $contextKind, string $attribute, mixed ...$values) 130 | { 131 | $newClause = [ 132 | "contextKind" => $contextKind, 133 | "attribute" => $attribute, 134 | "op" => 'in', 135 | "values" => $values, 136 | "negate" => true, 137 | ]; 138 | $this->_clauses[] = $newClause; 139 | return $this; 140 | } 141 | 142 | /** 143 | * Finishes defining the rule, specifying the result 144 | * value as a boolean or variation index. 145 | * 146 | * @param bool|int $variation the value to return if the rule matches the user 147 | * @return FlagBuilder the flag builder 148 | */ 149 | public function thenReturn(bool|int $variation): FlagBuilder 150 | { 151 | if (is_bool($variation)) { 152 | $this->_flagBuilder->booleanFlag(); 153 | return $this->thenReturn($this->_flagBuilder->variationForBoolean($variation)); 154 | } else { 155 | $this->_variation = $variation; 156 | $this->_flagBuilder->addRule($this); 157 | return $this->_flagBuilder; 158 | } 159 | } 160 | 161 | /** 162 | * Creates an associative array representation of the flag 163 | * 164 | * @param int $id the rule id 165 | * @return array the array representation of the flag 166 | */ 167 | public function build(int $id): array 168 | { 169 | return [ 170 | "id" => "rule{$id}", 171 | "variation" => $this->_variation, 172 | "clauses" => $this->_clauses 173 | ]; 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Integrations/TestData/MigrationSettingsBuilder.php: -------------------------------------------------------------------------------- 1 | checkRatio = $checkRatio; 14 | return $this; 15 | } 16 | 17 | /** 18 | * Creates an associative array representation of the migration settings 19 | * 20 | * @return array the array representation of the migration settings 21 | */ 22 | public function build(): array 23 | { 24 | return [ 25 | "checkRatio" => $this->checkRatio, 26 | ]; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/LaunchDarkly/LDContextMultiBuilder.php: -------------------------------------------------------------------------------- 1 | add(LDContext::create('my-user-key')) 22 | * ->add(LDContext::create('my-org-key', 'organization')) 23 | * ->build(); 24 | * ``` 25 | * 26 | * @see \LaunchDarkly\LDContext 27 | */ 28 | class LDContextMultiBuilder 29 | { 30 | private array $_contexts = []; 31 | 32 | /** 33 | * Creates an LDContext from the current builder properties. 34 | * 35 | * The LDContext is immutable and will not be affected by any subsequent actions on the 36 | * builder. 37 | * 38 | * It is possible for an LDContextMultiBuilder to represent an invalid state. Instead of 39 | * throwing an exception, the LDContextMultiBuilder always returns an LDContext, and you 40 | * can check {@see \LaunchDarkly\LDContext::isValid()} or 41 | * {@see \LaunchDarkly\LDContext::getError()} to see if it has an error. See 42 | * {@see \LaunchDarkly\LDContext::isValid()} for more information about invalid context 43 | * conditions. If you pass an invalid context to an SDK method, the SDK will 44 | * detect this and will log a description of the error. 45 | * 46 | * If only one context was added to the builder, this method returns that context rather 47 | * than a multi-context. 48 | * 49 | * @return LDContext a new LDContext 50 | */ 51 | public function build(): LDContext 52 | { 53 | if (count($this->_contexts) === 1) { 54 | return $this->_contexts[0]; // multi-context with only one context is the same as just that context 55 | } 56 | // LDContext constructor will handle validation 57 | return new LDContext(LDContext::MULTI_KIND, '', null, false, null, null, $this->_contexts, null); 58 | } 59 | 60 | /** 61 | * Adds an individual LDContext for a specific kind to the builer. 62 | * 63 | * It is invalid to add more than one LDContext for the same kind, or to add an LDContext 64 | * that is itself invalid. This error is detected when you call 65 | * {@see \LaunchDarkly\LDContextMultiBuilder::build()}. 66 | * 67 | * If the nested context is a multi-context, this is exactly equivalent to adding each of the 68 | * individual contexts from it separately. For instance, in the following example, `$multi1` and 69 | * `$multi2` end up being exactly the same: 70 | * ```php 71 | * $c1 = LDContext::create('key1', 'kind1'); 72 | * $c2 = LDContext::create('key2', 'kind2'); 73 | * $c3 = LDContext::create('key3', 'kind3');' 74 | * 75 | * $multi1 = LDContext::multiBuilder()->add($c1)->add($c2)->add($c3).build(); 76 | * 77 | * $c1plus2 = LDContext::multiBuilder()->add($c1)->add($c2).build(); 78 | * $multi2 = LDContext::multiBuilder()->add($c1plus2)->add($c3)->build(); 79 | * ``` 80 | * 81 | * @param LDContext $context the context to add 82 | * @return LDContextMultiBuilder the builder 83 | */ 84 | public function add(LDContext $context): LDContextMultiBuilder 85 | { 86 | if ($context->isMultiple()) { 87 | for ($i = 0; $i < $context->getIndividualContextCount(); $i++) { 88 | $c = $context->getIndividualContext($i); 89 | if ($c) { 90 | $this->add($c); 91 | } 92 | } 93 | return $this; 94 | } 95 | $this->_contexts[] = $context; 96 | return $this; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Migrations/ExecutionOrder.php: -------------------------------------------------------------------------------- 1 | client->migrationVariation($key, $context, $defaultStage); 40 | /** @var Stage */ 41 | $stage = $variationResult['stage']; 42 | /** @var OpTracker */ 43 | $tracker = $variationResult['tracker']; 44 | $tracker->operation(Operation::READ); 45 | 46 | $old = new Executor(Origin::OLD, $this->readConfig->old, $tracker, $this->trackLatency, $this->trackErrors, $payload); 47 | $new = new Executor(Origin::NEW, $this->readConfig->new, $tracker, $this->trackLatency, $this->trackErrors, $payload); 48 | 49 | $result = match ($stage) { 50 | Stage::OFF => $old->run(), 51 | Stage::DUALWRITE => $old->run(), 52 | Stage::SHADOW => $this->readBoth($old, $new, $tracker), 53 | Stage::LIVE => $this->readBoth($new, $old, $tracker), 54 | Stage::RAMPDOWN => $new->run(), 55 | Stage::COMPLETE => $new->run(), 56 | }; 57 | 58 | $this->client->trackMigrationOperation($tracker); 59 | 60 | return $result; 61 | } 62 | 63 | /** 64 | * Uses the provided flag key and context to execute a migration-backed write operation. 65 | */ 66 | public function write( 67 | string $key, 68 | LDContext $context, 69 | Stage $defaultStage, 70 | mixed $payload = null 71 | ): WriteResult { 72 | $variationResult = $this->client->migrationVariation($key, $context, $defaultStage); 73 | /** @var Stage */ 74 | $stage = $variationResult['stage']; 75 | /** @var OpTracker */ 76 | $tracker = $variationResult['tracker']; 77 | $tracker->operation(Operation::WRITE); 78 | 79 | $old = new Executor(Origin::OLD, $this->writeConfig->old, $tracker, $this->trackLatency, $this->trackErrors, $payload); 80 | $new = new Executor(Origin::NEW, $this->writeConfig->new, $tracker, $this->trackLatency, $this->trackErrors, $payload); 81 | 82 | $writeResult = match ($stage) { 83 | Stage::OFF => new WriteResult($old->run()), 84 | Stage::DUALWRITE => $this->writeBoth($old, $new, $tracker), 85 | Stage::SHADOW => $this->writeBoth($old, $new, $tracker), 86 | Stage::LIVE => $this->writeBoth($new, $old, $tracker), 87 | Stage::RAMPDOWN => $this->writeBoth($new, $old, $tracker), 88 | Stage::COMPLETE => new WriteResult($new->run()), 89 | }; 90 | 91 | $this->client->trackMigrationOperation($tracker); 92 | 93 | return $writeResult; 94 | } 95 | 96 | private function readBoth(Executor $authoritative, Executor $nonauthoritative, OpTracker $tracker): OperationResult 97 | { 98 | if ($this->executionOrder == ExecutionOrder::RANDOM && Util::sample(2)) { 99 | $nonauthoritativeResult = $nonauthoritative->run(); 100 | $authoritativeResult = $authoritative->run(); 101 | } else { 102 | $authoritativeResult = $authoritative->run(); 103 | $nonauthoritativeResult = $nonauthoritative->run(); 104 | } 105 | 106 | if ($this->readConfig->comparison === null) { 107 | return $authoritativeResult; 108 | } 109 | 110 | if ($authoritativeResult->isSuccessful() && $nonauthoritativeResult->isSuccessful()) { 111 | $tracker->consistent(fn (): bool => ($this->readConfig->comparison)($authoritativeResult->value, $nonauthoritativeResult->value)); 112 | } 113 | 114 | return $authoritativeResult; 115 | } 116 | 117 | private function writeBoth(Executor $authoritative, Executor $nonauthoritative, OpTracker $tracker): WriteResult 118 | { 119 | $authoritativeResult = $authoritative->run(); 120 | $tracker->invoked($authoritative->origin); 121 | 122 | if (!$authoritativeResult->isSuccessful()) { 123 | return new WriteResult($authoritativeResult); 124 | } 125 | 126 | $nonauthoritativeResult = $nonauthoritative->run(); 127 | $tracker->invoked($nonauthoritative->origin); 128 | 129 | return new WriteResult($authoritativeResult, $nonauthoritativeResult); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Migrations/MigratorBuilder.php: -------------------------------------------------------------------------------- 1 | readExecutionOrder = $order; 38 | return $this; 39 | } 40 | 41 | /** 42 | * Enable or disable latency tracking for migration operations. This 43 | * latency information can be sent upstream to LaunchDarkly to enhance 44 | * migration visibility. 45 | */ 46 | public function trackLatency(bool $track): MigratorBuilder 47 | { 48 | $this->trackLatency = $track; 49 | return $this; 50 | } 51 | 52 | /** 53 | * Enable or disable error tracking for migration operations. This error 54 | * information can be sent upstream to LaunchDarkly to enhance migration 55 | * visibility. 56 | */ 57 | public function trackErrors(bool $track): MigratorBuilder 58 | { 59 | $this->trackErrors = $track; 60 | return $this; 61 | } 62 | 63 | /** 64 | * Read can be used to configure the migration-read behavior of the 65 | * resulting migrator instance. 66 | * 67 | * Users are required to provide two different read methods -- one to read 68 | * from the old migration origin, and one to read from the new origin. 69 | * Additionally, customers can opt-in to consistency tracking by providing 70 | * a comparison function. 71 | * 72 | * Depending on the migration stage, one or both of these read methods may 73 | * be called. 74 | * 75 | * The read methods should accept a single nullable parameter. This 76 | * parameter is a payload passed through the {@see Migrator.read()} method. 77 | * This method should return a {@see Result} instance. 78 | * 79 | * The consistency method should accept 2 parameters of any type. These 80 | * parameters are the results of executing the read operation against the 81 | * old and new origins. If both operations were successful, the 82 | * consistency method will be invoked. This method should return true if 83 | * the two parameters are equal, or false otherwise. 84 | * 85 | * @param Closure(mixed): Result $old 86 | * @param Closure(mixed): Result $new 87 | * @param Closure(mixed,mixed): bool $comparison 88 | */ 89 | public function read(Closure $old, Closure $new, ?Closure $comparison = null): MigratorBuilder 90 | { 91 | $this->readConfig = new MigrationConfig($old, $new, $comparison); 92 | return $this; 93 | } 94 | 95 | /** 96 | * Write can be used to configure the migration-write behavior of the 97 | * resulting :class:`Migrator` instance. 98 | * 99 | * Users are required to provide two different write methods -- one to 100 | * write to the old migration origin, and one to write to the new origin. 101 | * 102 | * Depending on the migration stage, one or both of these write methods may 103 | * be called. 104 | * 105 | * The write methods should accept a single nullable parameter. This 106 | * parameter is a payload passed through the {@see Migrator.write()} method. 107 | * This method should return a {@see Result} instance. 108 | * 109 | * @param Closure(mixed): Result $old 110 | * @param Closure(mixed): Result $new 111 | */ 112 | public function write(Closure $old, Closure $new): MigratorBuilder 113 | { 114 | $this->writeConfig = new MigrationConfig($old, $new); 115 | return $this; 116 | } 117 | 118 | /** 119 | * Build constructs a Migrator instance to support migration-based 120 | * reads and writes. 121 | */ 122 | public function build(): Result 123 | { 124 | if ($this->readConfig === null) { 125 | return Result::error('read configuration not provided'); 126 | } 127 | 128 | if ($this->writeConfig === null) { 129 | return Result::error('write configuration not provided'); 130 | } 131 | 132 | return Result::success( 133 | new Migrator( 134 | $this->client, 135 | $this->readExecutionOrder, 136 | $this->readConfig, 137 | $this->writeConfig, 138 | $this->trackLatency, 139 | $this->trackErrors, 140 | ) 141 | ); 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Migrations/OpTracker.php: -------------------------------------------------------------------------------- 1 | consistentRatio = $flag?->getMigrationSettings()?->getCheckRatio() ?? 1; 42 | } 43 | 44 | 45 | 46 | /** 47 | * Sets the migration related Operation associated with these tracking measurements. 48 | */ 49 | public function operation(Operation $operation): OpTracker 50 | { 51 | $this->operation = $operation; 52 | return $this; 53 | } 54 | 55 | /** 56 | * Allows recording which {@see Origin}s were called during a migration. 57 | */ 58 | public function invoked(Origin $origin): OpTracker 59 | { 60 | $this->invoked[$origin->value] = true; 61 | return $this; 62 | } 63 | 64 | 65 | /** 66 | * Allows recording the results of a consistency check. 67 | * 68 | * This method accepts a callable which should take no parameters and return 69 | * a single boolean to represent the consistency check results for a read 70 | * operation. 71 | * 72 | * A callable is provided in case sampling rules do not require consistency 73 | * checking to run. In this case, we can avoid the overhead of a function by 74 | * not using the callable. 75 | * 76 | * @param callable $isConsistent Callable that accepts 0 parameters and must return a boolean 77 | */ 78 | public function consistent(callable $isConsistent): OpTracker 79 | { 80 | if (!Util::sample($this->consistentRatio)) { 81 | return $this; 82 | } 83 | 84 | try { 85 | $this->consistent = boolval($isConsistent()); 86 | } catch (Exception $e) { 87 | $msg = $e->getMessage(); 88 | $this->logger->error("exception raised during consistency check $msg; failed to record measurement"); 89 | } 90 | 91 | return $this; 92 | } 93 | 94 | /** 95 | * Allows recording whether an error occurred during the operation. 96 | */ 97 | public function error(Origin $origin): OpTracker 98 | { 99 | $this->errors[$origin->value] = true; 100 | return $this; 101 | } 102 | 103 | 104 | /** 105 | * Allows tracking the recorded latency for an individual operation. 106 | */ 107 | public function latency(Origin $origin, float $elapsedMs): OpTracker 108 | { 109 | $this->latencies[$origin->value] = $elapsedMs; 110 | return $this; 111 | } 112 | 113 | 114 | /** 115 | * Returns an array representing a migration operation event. 116 | * 117 | * This event data can be provided to {@see 118 | * \LaunchDarkly\LDClient::trackMigrationOp()} to relay this metric 119 | * information upstream to LaunchDarkly services. 120 | * 121 | * @return array|string 122 | */ 123 | public function build(): array|string 124 | { 125 | if (!$this->operation) { 126 | return "operation not provided"; 127 | } elseif (strlen($this->key) === 0) { 128 | return "migration operation cannot contain an empty key"; 129 | } elseif (count($this->invoked) === 0) { 130 | return "no origins were invoked"; 131 | } elseif (!$this->context->isValid()) { 132 | return "provided context was invalid"; 133 | } 134 | 135 | $error = $this->checkInvokedConsistency(); 136 | if ($error !== null) { 137 | return $error; 138 | } 139 | 140 | $event = [ 141 | 'kind' => 'migration_op', 142 | 'creationDate' => Util::currentTimeUnixMillis(), 143 | 'context' => $this->context, 144 | 'operation' => $this->operation->value, 145 | 'evaluation' => [ 146 | 'key' => $this->key, 147 | 'value' => $this->detail->getValue(), 148 | 'default' => $this->default_stage->value, 149 | 'reason' => $this->detail->getReason()->jsonSerialize(), 150 | ], 151 | 152 | 'measurements' => [ 153 | [ 154 | 'key' => 'invoked', 155 | 'values' => $this->invoked, 156 | ] 157 | ], 158 | ]; 159 | 160 | if ($this->flag) { 161 | $event['evaluation']['version'] = $this->flag->getVersion(); 162 | 163 | if ($this->flag->getSamplingRatio() !== 1) { 164 | $event['samplingRatio'] = $this->flag->getSamplingRatio(); 165 | } 166 | } 167 | 168 | if ($this->detail->getVariationIndex() !== null) { 169 | $event['evaluation']['variation'] = $this->detail->getVariationIndex(); 170 | } 171 | 172 | if ($this->consistent !== null) { 173 | $measurement = [ 174 | 'key' => 'consistent', 175 | 'value' => $this->consistent, 176 | ]; 177 | 178 | if ($this->consistentRatio !== 1) { 179 | $measurement['samplingRatio'] = $this->consistentRatio; 180 | } 181 | 182 | $event['measurements'][] = $measurement; 183 | } 184 | 185 | if (count($this->errors)) { 186 | $event['measurements'][] = [ 187 | 'key' => 'error', 188 | 'values' => $this->errors, 189 | ]; 190 | } 191 | 192 | if (count($this->latencies)) { 193 | $event['measurements'][] = [ 194 | 'key' => 'latency_ms', 195 | 'values' => $this->latencies, 196 | ]; 197 | } 198 | 199 | return $event; 200 | } 201 | 202 | private function checkInvokedConsistency(): ?string 203 | { 204 | foreach (Origin::cases() as $origin) { 205 | $originValue = $origin->value; 206 | if (isset($this->invoked[$originValue])) { 207 | continue; 208 | } 209 | 210 | if (isset($this->latencies[$originValue])) { 211 | return "provided latency for origin {$originValue} without recording invocation"; 212 | } 213 | 214 | if (isset($this->errors[$originValue])) { 215 | return "provided error for origin {$originValue} without recording invocation"; 216 | } 217 | } 218 | 219 | if ($this->consistent !== null && count($this->invoked) !== 2) { 220 | return "provided consistency without recording both invocations"; 221 | } 222 | 223 | return null; 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Migrations/Operation.php: -------------------------------------------------------------------------------- 1 | value = $result->value; 24 | $this->error = $result->error; 25 | $this->exception = $result->exception; 26 | } 27 | 28 | /** 29 | * Determine whether this result represents success or failure. 30 | */ 31 | public function isSuccessful(): bool 32 | { 33 | return $this->result->isSuccessful(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Migrations/Origin.php: -------------------------------------------------------------------------------- 1 | DUALWRITE -> SHADOW -> LIVE -> RAMPDOWN -> COMPLETE 12 | */ 13 | enum Stage: string 14 | { 15 | /** 16 | * The migration hasn't started. 'old' is authoritative for reads and writes 17 | */ 18 | case OFF = 'off'; 19 | 20 | /** 21 | * Write to both 'old' and 'new', 'old' is authoritative for reads 22 | */ 23 | case DUALWRITE = 'dualwrite'; 24 | 25 | /** 26 | * Both 'new' and 'old' versions run with a preference for 'old' 27 | */ 28 | case SHADOW = 'shadow'; 29 | 30 | /** 31 | * Both 'new' and 'old' versions run with a preference for 'new' 32 | */ 33 | case LIVE = 'live'; 34 | 35 | /** 36 | * Only read from 'new', write to 'old' and 'new' 37 | */ 38 | case RAMPDOWN = 'rampdown'; 39 | 40 | /** 41 | * The migration is finished. 'new' is authoritative for reads and writes 42 | */ 43 | case COMPLETE = 'complete'; 44 | } 45 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Migrations/WriteResult.php: -------------------------------------------------------------------------------- 1 | |null A map from segment reference to inclusion status 49 | */ 50 | public function getMembership(string $contextHash): ?array; 51 | } 52 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Subsystems/EventPublisher.php: -------------------------------------------------------------------------------- 1 | |null The decoded FeatureFlags, or null if missing 43 | */ 44 | public function getAllFeatures(): ?array; 45 | } 46 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Types/ApplicationInfo.php: -------------------------------------------------------------------------------- 1 | id = null; 33 | $this->version = null; 34 | $this->errors = []; 35 | } 36 | 37 | /** 38 | * Set the application id metadata identifier. 39 | */ 40 | public function withId(string $id): ApplicationInfo 41 | { 42 | $this->id = $this->validateValue($id, 'id'); 43 | 44 | return $this; 45 | } 46 | 47 | /** 48 | * Set the application version metadata identifier. 49 | */ 50 | public function withVersion(string $version): ApplicationInfo 51 | { 52 | $this->version = $this->validateValue($version, 'version'); 53 | 54 | return $this; 55 | } 56 | 57 | /** 58 | * Retrieve any validation errors that have accumulated as a result of creating this instance. 59 | */ 60 | public function errors(): array 61 | { 62 | return array_values($this->errors); 63 | } 64 | 65 | public function __toString(): string 66 | { 67 | $parts = []; 68 | 69 | if ($this->id !== null) { 70 | $parts[] = "application-id/{$this->id}"; 71 | } 72 | 73 | if ($this->version !== null) { 74 | $parts[] = "application-version/{$this->version}"; 75 | } 76 | 77 | return join(" ", $parts); 78 | } 79 | 80 | private function validateValue(string $value, string $label): ?string 81 | { 82 | $value = strval($value); 83 | 84 | if ($value === '') { 85 | return null; 86 | } 87 | 88 | if (strlen($value) > 64) { 89 | $this->errors[$label] = "Application value for $label was longer than 64 characters and was discarded"; 90 | return null; 91 | } 92 | 93 | if (preg_match('/[^a-zA-Z0-9._-]/', $value)) { 94 | $this->errors[$label] = "Application value for $label contained invalid characters and was discarded"; 95 | return null; 96 | } 97 | 98 | return $value; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Types/AttributeReference.php: -------------------------------------------------------------------------------- 1 | _path = $path; 41 | $this->_singleComponent = $singleComponent; 42 | $this->_components = $components; 43 | $this->_error = $error; 44 | } 45 | 46 | /** 47 | * Creates an AttributeReference from a string. For the supported syntax and examples, see 48 | * comments on the {@see \LaunchDarkly\AttributeReference} type. 49 | * 50 | * This method always returns an AttributeRef that preserves the original string, even if 51 | * validation fails. If validation fails, {@see \LaunchDarkly\AttributeReference::getError()} will 52 | * return a non-null error and any SDK method that takes this AttributeReference as a parameter 53 | * will consider it invalid. 54 | * 55 | * @param string $refPath an attribute name or path 56 | * @return AttributeReference the parsed reference 57 | */ 58 | public static function fromPath(string $refPath): AttributeReference 59 | { 60 | if ($refPath === '' || $refPath === '/') { 61 | return self::failed($refPath, self::ERR_ATTR_EMPTY); 62 | } 63 | if (!str_starts_with($refPath, '/')) { 64 | return new AttributeReference($refPath, $refPath, null, null); 65 | } 66 | $components = explode('/', substr($refPath, 1)); 67 | if (count($components) === 1) { 68 | $attr = self::unescape($components[0]); 69 | if ($attr === null) { 70 | return self::failed($refPath, self::ERR_ATTR_INVALID_ESCAPE); 71 | } 72 | return new AttributeReference($refPath, $attr, null, null); 73 | } 74 | for ($i = 0; $i < count($components); $i++) { 75 | $prop = $components[$i]; 76 | if ($prop === '') { 77 | return self::failed($refPath, self::ERR_ATTR_EXTRA_SLASH); 78 | } 79 | $prop = self::unescape($prop); 80 | if ($prop === null) { 81 | return self::failed($refPath, self::ERR_ATTR_INVALID_ESCAPE); 82 | } 83 | $components[$i] = $prop; 84 | } 85 | return new AttributeReference($refPath, null, $components, null); 86 | } 87 | 88 | /** 89 | * Similar to {@see \LaunchDarkly\AttributeReference::fromPath()}, except that it always 90 | * interprets the string as a literal attribute name, never as a slash-delimited path expression. 91 | * 92 | * There is no escaping or unescaping, even if the name contains literal '/' or '~' characters. 93 | * Since an attribute name can contain any characters, this method always returns a valid 94 | * AttributeReference unless the name is empty. 95 | * 96 | * @param string $attributeName an attribute name 97 | * @param AttributeReference the reference 98 | */ 99 | public static function fromLiteral(string $attributeName): AttributeReference 100 | { 101 | if ($attributeName === '') { 102 | return self::failed($attributeName, self::ERR_ATTR_EMPTY); 103 | } 104 | // If the attribute name starts with a slash, we need to compute the escaped version so 105 | // getPath() will always return a valid attribute reference path. This matters because 106 | // lists of redacted attributes in events always use the path format. 107 | $refPath = str_starts_with($attributeName, '/') ? 108 | ('/' . self::escape($attributeName)) : 109 | $attributeName; 110 | return new AttributeReference($refPath, $attributeName, null, null); 111 | } 112 | 113 | private static function failed(string $refPath, string $error): AttributeReference 114 | { 115 | return new AttributeReference($refPath, null, null, $error); 116 | } 117 | 118 | /** 119 | * Returns the original attribute reference path string. 120 | */ 121 | public function getPath(): string 122 | { 123 | return $this->_path; 124 | } 125 | 126 | /** 127 | * Returns null for a valid reference, or an error string for an invalid one. 128 | * 129 | * @return ?string an error string or null 130 | */ 131 | public function getError(): ?string 132 | { 133 | return $this->_error; 134 | } 135 | 136 | /** 137 | * The number of path components in the AttributeReference. 138 | * 139 | * For a simple attribute reference such as "name" with no leading slash, this returns 1. 140 | * 141 | * For an attribute reference with a leading slash, it is the number of slash-delimited path 142 | * components after the initial slash. For instance, for "/a/b" it returns 2. 143 | * 144 | * For an invalid attribute reference, it returns zero. 145 | * 146 | * @return int the number of path components 147 | */ 148 | public function getDepth(): int 149 | { 150 | return $this->_components === null ? 1 : count($this->_components); 151 | } 152 | 153 | /** 154 | * Retrieves a single path component from the attribute reference. 155 | * 156 | * For a simple attribute reference such as "name" with no leading slash, it returns the 157 | * attribute name if index is zero, and an empty string otherwise. 158 | * 159 | * For an attribute reference with a leading slash, if index is non-negative and less than 160 | * getDepth(), it returns the path component string at that position. 161 | * 162 | * @param int index the zero-based index of the desired path component 163 | * @return string the path component, or an empty string if not available 164 | */ 165 | public function getComponent(int $index): string 166 | { 167 | if ($this->_components === null) { 168 | return $index === 0 ? ($this->_singleComponent ?: '') : ''; 169 | } 170 | return $index < 0 || $index >= count($this->_components) ? '' : $this->_components[$index]; 171 | } 172 | 173 | private static function unescape(string $s): ?string 174 | { 175 | if (preg_match('/(~[^01]|~$)/', $s)) { 176 | return null; 177 | } 178 | return str_replace('~0', '~', str_replace('~1', '/', $s)); 179 | } 180 | 181 | private static function escape(string $s): ?string 182 | { 183 | return str_replace('/', '~1', str_replace('~', '~0', $s)); 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Types/BigSegmentsConfig.php: -------------------------------------------------------------------------------- 1 | statusPollInterval = $statusPollInterval === null || $statusPollInterval < 0 ? self::DEFAULT_STATUS_POLL_INTERVAL : $statusPollInterval; 60 | $this->staleAfter = $staleAfter === null || $staleAfter < 0 ? self::DEFAULT_STALE_AFTER : $staleAfter; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Types/BigSegmentsStoreMetadata.php: -------------------------------------------------------------------------------- 1 | lastUpToDate; 23 | } 24 | 25 | /** 26 | * Returns true if the metadata is considered stale, based on the current 27 | * time and the provided staleAfter seconds value. If the metadata has never 28 | * been updated, it is considered stale. 29 | */ 30 | public function isStale(int $staleAfter): bool 31 | { 32 | if ($this->lastUpToDate === null) { 33 | return true; 34 | } 35 | 36 | return time() - $this->lastUpToDate >= $staleAfter; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Types/BigSegmentsStoreStatus.php: -------------------------------------------------------------------------------- 1 | available; 34 | } 35 | 36 | /** 37 | * True if the Big Segment store is available, but has not been updated 38 | * within the amount of time specified by {@see 39 | * LaunchDarkly\Types\BigSegmentsConfig::$staleAfter}. 40 | * 41 | * This may indicate that the LaunchDarkly Relay Proxy, which populates the 42 | * store, has stopped running or has become unable to receive fresh data 43 | * from LaunchDarkly. Any feature flag evaluations that reference a Big 44 | * Segment will be using the last known data, which may be out of date. 45 | * Also, the {@see LaunchDarkly\EvaluationReason} associated with those 46 | * evaluations will have a `big_segments_status` of `STALE`. 47 | */ 48 | public function isStale(): bool 49 | { 50 | return $this->stale; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/LaunchDarkly/Types/Result.php: -------------------------------------------------------------------------------- 1 | error === null; 61 | } 62 | } 63 | --------------------------------------------------------------------------------