Skip to content

Commit 0053143

Browse files
authored
(U2C #12) improve error handling/logging in evaluations (#113)
1 parent 86a853a commit 0053143

File tree

6 files changed

+124
-56
lines changed

6 files changed

+124
-56
lines changed

Makefile

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,7 @@ TEMP_TEST_OUTPUT=/tmp/sse-contract-test-service.log
1616
# arrays work, there's no way to disallow an array index lookup without breaking object property
1717
# lookups for properties that are numeric strings.
1818
# - "evaluation/parameterized/prerequisites": Can't pass yet because prerequisite cycle detection is not implemented.
19-
# - "evaluation/parameterized/segment match": Haven't yet implemented context kinds in segments.
2019
# - "evaluation/parameterized/segment recursion": Haven't yet implemented segment recursion.
21-
# - various other "evaluation" subtests: These tests require context kind support.
2220
# - "events": These test suites will be unavailable until more of the U2C implementation is done.
2321
TEST_HARNESS_PARAMS := $(TEST_HARNESS_PARAMS) \
2422
-skip 'evaluation/bucketing/secondary' \

src/LaunchDarkly/Impl/Evaluation/Evaluator.php

Lines changed: 55 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
use LaunchDarkly\Impl\Model\Rule;
1111
use LaunchDarkly\Impl\Model\Segment;
1212
use LaunchDarkly\Impl\Model\SegmentRule;
13+
use LaunchDarkly\Impl\Util;
1314
use LaunchDarkly\LDContext;
15+
use Psr\Log\LoggerInterface;
1416

1517
/**
1618
* Encapsulates the feature flag evaluation logic. The Evaluator has no direct access to the
@@ -23,10 +25,12 @@
2325
class Evaluator
2426
{
2527
private FeatureRequester $_featureRequester;
28+
private LoggerInterface $_logger;
2629

27-
public function __construct(FeatureRequester $featureRequester)
30+
public function __construct(FeatureRequester $featureRequester, ?LoggerInterface $logger = null)
2831
{
2932
$this->_featureRequester = $featureRequester;
33+
$this->_logger = $logger ?: Util::makeNullLogger();
3034
}
3135

3236
/**
@@ -54,40 +58,52 @@ private function evaluateInternal(
5458
LDContext $context,
5559
?callable $prereqEvalSink
5660
): EvalResult {
57-
if (!$flag->isOn()) {
58-
return EvaluatorHelpers::getOffResult($flag, EvaluationReason::off());
59-
}
61+
try {
62+
// The reason there's an extra try block here is that if something fails during evaluation of the
63+
// prerequisites, we don't want that to short-circuit evaluation of the base flag in the way that
64+
// an error normally would, where the whole evaluation would return an error result with a default
65+
// value. Instead we want the base flag to return its off variation, as it always does if any
66+
// prerequisites have failed.
67+
if (!$flag->isOn()) {
68+
return EvaluatorHelpers::getOffResult($flag, EvaluationReason::off());
69+
}
6070

61-
$prereqFailureReason = $this->checkPrerequisites($flag, $context, $prereqEvalSink);
62-
if ($prereqFailureReason !== null) {
63-
return EvaluatorHelpers::getOffResult($flag, $prereqFailureReason);
64-
}
71+
$prereqFailureReason = $this->checkPrerequisites($flag, $context, $prereqEvalSink);
72+
if ($prereqFailureReason !== null) {
73+
return EvaluatorHelpers::getOffResult($flag, $prereqFailureReason);
74+
}
6575

66-
// Check to see if targets match
67-
$targetResult = $this->checkTargets($flag, $context);
68-
if ($targetResult) {
69-
return $targetResult;
70-
}
76+
// Check to see if targets match
77+
$targetResult = $this->checkTargets($flag, $context);
78+
if ($targetResult) {
79+
return $targetResult;
80+
}
7181

72-
// Now walk through the rules and see if any match
73-
foreach ($flag->getRules() as $i => $rule) {
74-
if ($this->ruleMatchesContext($rule, $context)) {
75-
return EvaluatorHelpers::getResultForVariationOrRollout(
76-
$flag,
77-
$rule,
78-
$rule->isTrackEvents(),
79-
$context,
80-
EvaluationReason::ruleMatch($i, $rule->getId())
81-
);
82+
// Now walk through the rules and see if any match
83+
foreach ($flag->getRules() as $i => $rule) {
84+
if ($this->ruleMatchesContext($rule, $context)) {
85+
return EvaluatorHelpers::getResultForVariationOrRollout(
86+
$flag,
87+
$rule,
88+
$rule->isTrackEvents(),
89+
$context,
90+
EvaluationReason::ruleMatch($i, $rule->getId())
91+
);
92+
}
8293
}
94+
return EvaluatorHelpers::getResultForVariationOrRollout(
95+
$flag,
96+
$flag->getFallthrough(),
97+
$flag->isTrackEventsFallthrough(),
98+
$context,
99+
EvaluationReason::fallthrough()
100+
);
101+
} catch (EvaluationException $e) {
102+
return new EvalResult(new EvaluationDetail(null, null, EvaluationReason::error($e->getErrorKind())));
103+
} catch (\Throwable $e) {
104+
Util::logExceptionAtErrorLevel($this->_logger, $e, 'Unexpected error when evaluating flag ' . $flag->getKey());
105+
return new EvalResult(new EvaluationDetail(null, null, EvaluationReason::error(EvaluationReason::EXCEPTION_ERROR)));
83106
}
84-
return EvaluatorHelpers::getResultForVariationOrRollout(
85-
$flag,
86-
$flag->getFallthrough(),
87-
$flag->isTrackEventsFallthrough(),
88-
$context,
89-
EvaluationReason::fallthrough()
90-
);
91107
}
92108

93109
private function checkPrerequisites(
@@ -99,9 +115,11 @@ private function checkPrerequisites(
99115
$prereqOk = true;
100116
try {
101117
$prereqFeatureFlag = $this->_featureRequester->getFeature($prereq->getKey());
102-
if ($prereqFeatureFlag == null) {
118+
if ($prereqFeatureFlag === null) {
103119
$prereqOk = false;
104120
} else {
121+
// Note that if the prerequisite flag is off, we don't consider it a match no matter what its
122+
// off variation was. But we still need to evaluate it in order to generate an event.
105123
$prereqEvalResult = $this->evaluateInternal($prereqFeatureFlag, $context, $prereqEvalSink);
106124
$variation = $prereq->getVariation();
107125
if (!$prereqFeatureFlag->isOn() || $prereqEvalResult->getDetail()->getVariationIndex() !== $variation) {
@@ -111,7 +129,12 @@ private function checkPrerequisites(
111129
$prereqEvalSink(new PrerequisiteEvaluationRecord($prereqFeatureFlag, $flag, $prereqEvalResult));
112130
}
113131
}
114-
} catch (\Exception $e) {
132+
} catch (\Throwable $e) {
133+
Util::logExceptionAtErrorLevel(
134+
$this->_logger,
135+
$e,
136+
'Unexpected error when evaluating prerequisite flag ' . $prereq->getKey()
137+
);
115138
$prereqOk = false;
116139
}
117140
if (!$prereqOk) {

src/LaunchDarkly/Impl/Util.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66

77
use DateTime;
88
use DateTimeZone;
9+
use Monolog\Handler\NullHandler;
10+
use Monolog\Logger;
11+
use Psr\Log\LoggerInterface;
912

1013
/**
1114
* Internal class containing helper methods.
@@ -50,4 +53,15 @@ public static function httpErrorMessage(int $status, string $context, string $re
5053
. ' for ' . $context . ' - '
5154
. (Util::isHttpErrorRecoverable($status) ? $retryMessage : 'giving up permanently');
5255
}
56+
57+
public static function logExceptionAtErrorLevel(LoggerInterface $logger, \Throwable $e, string $message): void
58+
{
59+
$logger->error($message . ': ' . $e->getMessage());
60+
$logger->debug("$e");
61+
}
62+
63+
public static function makeNullLogger(): LoggerInterface
64+
{
65+
return new Logger('', [new NullHandler()]);
66+
}
5367
}

src/LaunchDarkly/LDClient.php

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
use LaunchDarkly\Impl\Model\FeatureFlag;
1313
use LaunchDarkly\Impl\PreloadedFeatureRequester;
1414
use LaunchDarkly\Impl\UnrecoverableHTTPStatusException;
15+
use LaunchDarkly\Impl\Util;
1516
use LaunchDarkly\Integrations\Guzzle;
1617
use Monolog\Handler\ErrorLogHandler;
1718
use Monolog\Logger;
@@ -138,7 +139,7 @@ public function __construct(string $sdkKey, array $options = [])
138139

139140
$this->_featureRequester = $this->getFeatureRequester($sdkKey, $options);
140141

141-
$this->_evaluator = new Evaluator($this->_featureRequester);
142+
$this->_evaluator = new Evaluator($this->_featureRequester, $this->_logger);
142143
}
143144

144145
/**
@@ -279,13 +280,9 @@ function (PrerequisiteEvaluationRecord $pe) {
279280
$sendEvent($detail, $flag);
280281
return $detail;
281282
} catch (\Exception $e) {
282-
$this->_logger->error("Caught $e");
283+
Util::logExceptionAtErrorLevel($this->_logger, $e, "Unexpected error evaluating flag $key");
283284
$result = $errorResult(EvaluationReason::EXCEPTION_ERROR);
284-
try {
285-
$sendEvent($result, null);
286-
} catch (\Exception $e) {
287-
$this->_logger->error("Caught $e");
288-
}
285+
$sendEvent($result, null);
289286
return $result;
290287
}
291288
}

test-service/TestService.php

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ public function __construct(TestDataStore $store, Logger $logger)
2626
});
2727

2828
$this->_app->route('POST /', function () {
29-
$params = $this->_app->request()->data;
29+
$params = self::parseRequestJson($this->_app->request()->getBody());
3030
$id = $this->createClient($params);
3131
header("Location:/clients/$id");
3232
});
@@ -37,7 +37,8 @@ public function __construct(TestDataStore $store, Logger $logger)
3737
http_response_code(404);
3838
return;
3939
}
40-
$resp = $c->doCommand($this->_app->request()->data);
40+
$params = self::parseRequestJson($this->_app->request()->getBody());
41+
$resp = $c->doCommand($params);
4142
if ($resp === false) {
4243
http_response_code(400);
4344
} elseif (is_array($resp)) {
@@ -102,4 +103,39 @@ private function getClient(string $id): ?SdkClientEntity
102103
}
103104
return new SdkClientEntity($params);
104105
}
106+
107+
// The following methods for normalizing parsed JSON are a workaround for PHP's inability to distinguish
108+
// between an empty JSON array [] and an empty JSON object {} if you parse JSON into associative arrays.
109+
// In order for some contract tests to work which involve empty object values, we need to be able to
110+
// make such a distinction. But, we don't want to parse all of the JSON parameters as objects, because
111+
// associative arrays are much more convenient for most of our logic. The solution is to parse everything
112+
// as an object first, then convert every object to an array UNLESS it is an empty object.
113+
114+
private static function parseRequestJson(string $json): array
115+
{
116+
return self::normalizeParsedData(json_decode($json));
117+
}
118+
119+
private static function normalizeParsedData(mixed $value): mixed
120+
{
121+
if (is_array($value)) {
122+
$ret = [];
123+
foreach ($value as $element) {
124+
$ret[] = self::normalizeParsedData($element);
125+
}
126+
return $ret;
127+
}
128+
if (!is_object($value)) {
129+
return $value;
130+
}
131+
$props = get_object_vars($value);
132+
if (count($props) === 0) {
133+
return $value;
134+
}
135+
$ret = [];
136+
foreach ($props as $k => $v) {
137+
$ret[$k] = self::normalizeParsedData($v);
138+
}
139+
return $ret;
140+
}
105141
}

tests/Impl/Evaluation/EvaluatorFlagTest.php

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -95,22 +95,22 @@ public function testFlagReturnsOffVariationIfPrerequisiteIsNotFound()
9595

9696
public function testFlagReturnsOffVariationAndEventIfPrerequisiteIsOff()
9797
{
98-
$flag0 = ModelBuilders::flagBuilder('feature0')->variations('fall', 'off', 'on')
99-
->on(true)->offVariation(1)->fallthroughVariation(0)
100-
->prerequisite('feature1', 1)
101-
->build();
10298
$flag1 = ModelBuilders::flagBuilder('feature1')->variations('nogo', 'go')
10399
->on(false)->offVariation(1)->fallthroughVariation(0)
104100
// note that even though it returns the desired variation, it is still off and therefore not a match
105101
->build();
102+
$flag0 = ModelBuilders::flagBuilder('feature0')->variations('fall', 'off', 'on')
103+
->on(true)->offVariation(1)->fallthroughVariation(0)
104+
->prerequisite($flag1->getKey(), 1)
105+
->build();
106106

107107
$requester = new MockFeatureRequester();
108108
$requester->addFlag($flag1);
109109
$evaluator = new Evaluator($requester);
110110
$recorder = EvaluatorTestUtil::prerequisiteRecorder();
111111

112112
$result = $evaluator->evaluate($flag0, LDContext::create('user'), $recorder->record());
113-
$detail = new EvaluationDetail('off', 1, EvaluationReason::prerequisiteFailed('feature1'));
113+
$detail = new EvaluationDetail('off', 1, EvaluationReason::prerequisiteFailed($flag1->getKey()));
114114
self::assertEquals($detail, $result->getDetail());
115115

116116
self::assertEquals(1, count($recorder->evals));
@@ -122,12 +122,12 @@ public function testFlagReturnsOffVariationAndEventIfPrerequisiteIsOff()
122122

123123
public function testFlagReturnsOffVariationAndEventIfPrerequisiteIsNotMet()
124124
{
125-
$flag0 = ModelBuilders::flagBuilder('feature0')->variations('fall', 'off', 'on')
125+
$flag1 = ModelBuilders::flagBuilder('feature1')->variations('nogo', 'go')
126126
->on(true)->offVariation(1)->fallthroughVariation(0)
127-
->prerequisite('feature1', 1)
128127
->build();
129-
$flag1 = ModelBuilders::flagBuilder('feature1')->variations('nogo', 'go')
128+
$flag0 = ModelBuilders::flagBuilder('feature0')->variations('fall', 'off', 'on')
130129
->on(true)->offVariation(1)->fallthroughVariation(0)
130+
->prerequisite($flag1->getKey(), 1)
131131
->build();
132132

133133
$requester = new MockFeatureRequester();
@@ -136,7 +136,7 @@ public function testFlagReturnsOffVariationAndEventIfPrerequisiteIsNotMet()
136136
$recorder = EvaluatorTestUtil::prerequisiteRecorder();
137137

138138
$result = $evaluator->evaluate($flag0, LDContext::create('user'), $recorder->record());
139-
$detail = new EvaluationDetail('off', 1, EvaluationReason::prerequisiteFailed('feature1'));
139+
$detail = new EvaluationDetail('off', 1, EvaluationReason::prerequisiteFailed($flag1->getKey()));
140140
self::assertEquals($detail, $result->getDetail());
141141

142142
self::assertEquals(1, count($recorder->evals));
@@ -148,13 +148,13 @@ public function testFlagReturnsOffVariationAndEventIfPrerequisiteIsNotMet()
148148

149149
public function testFlagReturnsFallthroughVariationAndEventIfPrerequisiteIsMetAndThereAreNoRules()
150150
{
151-
$flag0 = ModelBuilders::flagBuilder('feature0')->variations('fall', 'off', 'on')
152-
->on(true)->fallthroughVariation(0)
153-
->prerequisite('feature1', 1)
154-
->build();
155151
$flag1 = ModelBuilders::flagBuilder('feature1')->variations('nogo', 'go')
156152
->on(true)->offVariation(1)->fallthroughVariation(1)
157153
->build();
154+
$flag0 = ModelBuilders::flagBuilder('feature0')->variations('fall', 'off', 'on')
155+
->on(true)->fallthroughVariation(0)
156+
->prerequisite($flag1->getKey(), 1)
157+
->build();
158158

159159
$requester = new MockFeatureRequester();
160160
$requester->addFlag($flag1);

0 commit comments

Comments
 (0)