Skip to content

Commit cc9a87b

Browse files
authored
feat: Add migration op tracker (#130)
1 parent d73bcc6 commit cc9a87b

File tree

13 files changed

+674
-15
lines changed

13 files changed

+674
-15
lines changed

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
"kevinrob/guzzle-cache-middleware": "^4.0",
2525
"phpunit/php-code-coverage": "^9",
2626
"phpunit/phpunit": "^9",
27-
"vimeo/psalm": "^4.9"
27+
"vimeo/psalm": "^5.15"
2828
},
2929
"suggest": {
3030
"guzzlehttp/guzzle": "(^6.3 | ^7) Required when using GuzzleEventPublisher or the default FeatureRequester",

src/LaunchDarkly/Impl/Events/EventProcessor.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ class EventProcessor
2121
private int $_capacity;
2222

2323
/**
24-
* @psalm-param array{capacity: int} $options
24+
* @param array<string, mixed> $options
2525
*/
2626
public function __construct(string $sdkKey, array $options)
2727
{

src/LaunchDarkly/Impl/SemanticVersion.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
*/
1818
class SemanticVersion
1919
{
20-
private static string $REGEX = '/^(?<major>0|[1-9]\d*)(\.(?<minor>0|[1-9]\d*))?(\.(?<patch>0|[1-9]\d*))?(\-(?<prerel>[0-9A-Za-z\-\.]+))?(\+(?<build>[0-9A-Za-z\-\.]+))?$/';
20+
private const REGEX = '/^(?<major>0|[1-9]\d*)(\.(?<minor>0|[1-9]\d*))?(\.(?<patch>0|[1-9]\d*))?(\-(?<prerel>[0-9A-Za-z\-\.]+))?(\+(?<build>[0-9A-Za-z\-\.]+))?$/';
2121

2222
public int $major;
2323
public int $minor;
@@ -47,7 +47,7 @@ public function __construct(
4747
*/
4848
public static function parse(string $input, bool $loose = false): SemanticVersion
4949
{
50-
if (!preg_match(self::$REGEX, $input, $matches)) {
50+
if (!preg_match(self::REGEX, $input, $matches)) {
5151
throw new \InvalidArgumentException("not a valid semantic version");
5252
}
5353
$major = intval($matches['major']);

src/LaunchDarkly/Impl/Util.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ public static function adjustBaseUri(string $uri): string
3131

3232
public static function dateTimeToUnixMillis(DateTime $dateTime): int
3333
{
34-
$timeStampSeconds = (int)$dateTime->getTimestamp();
34+
$timeStampSeconds = $dateTime->getTimestamp();
3535
$timestampMicros = (int)$dateTime->format('u');
3636
return $timeStampSeconds * 1000 + (int)($timestampMicros / 1000);
3737
}

src/LaunchDarkly/LDClient.php

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,6 @@ class LDClient
5252
/**
5353
* Creates a new client instance that connects to LaunchDarkly.
5454
*
55-
* @psalm-param array{capacity?: int, defaults?: array<string, mixed|null>} $options
56-
*
5755
* @param string $sdkKey The SDK key for your account
5856
* @param array $options Client configuration settings
5957
* - `base_uri`: Base URI of the LaunchDarkly service. Change this if you are connecting to a Relay Proxy instance instead of
@@ -153,6 +151,11 @@ public function __construct(string $sdkKey, array $options = [])
153151
$this->_evaluator = new Evaluator($this->_featureRequester, $this->_logger);
154152
}
155153

154+
public function getLogger(): LoggerInterface
155+
{
156+
return $this->_logger;
157+
}
158+
156159
/**
157160
* @param string $sdkKey
158161
* @param mixed[] $options

src/LaunchDarkly/LDContext.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,30 @@ public function getKind(): string
453453
return $this->_kind;
454454
}
455455

456+
/**
457+
* Returns an associate array mapping each context kind to its key.
458+
*
459+
* If the context is invalid, this will return an empty array. A single
460+
* kind context will return an array with a single mapping.
461+
*/
462+
public function getKeys(): array
463+
{
464+
if (!$this->isValid()) {
465+
return [];
466+
}
467+
468+
if ($this->_multiContexts !== null) {
469+
$result = [];
470+
foreach ($this->_multiContexts as $context) {
471+
$result[$context->getKind()] = $context->getKey();
472+
}
473+
474+
return $result;
475+
}
476+
477+
return [$this->getKind() => $this->getKey()];
478+
}
479+
456480
/**
457481
* Returns the context's `key` attribute.
458482
*

src/LaunchDarkly/LDContextMultiBuilder.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
/**
88
* A mutable object that uses the builder pattern to specify properties for a multi-context.
99
*
10-
* Use this builder if you need to construct an {@see \LaunchDarkly\LDContext) that contains
10+
* Use this builder if you need to construct an {@see \LaunchDarkly\LDContext} that contains
1111
* multiple contexts, each for a different context kind. To define a regular context for a
1212
* single kind, use {@see \LaunchDarkly\LDContext::create()} or
1313
* {@see \LaunchDarkly\LDContext::builder()}.
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace LaunchDarkly\Migrations;
6+
7+
use Exception;
8+
use LaunchDarkly\EvaluationDetail;
9+
use LaunchDarkly\Impl;
10+
use LaunchDarkly\Impl\Util;
11+
use LaunchDarkly\LDContext;
12+
use LaunchDarkly\LDUser;
13+
use Psr\Log\LoggerInterface;
14+
15+
/**
16+
* An OpTracker is responsible for managing the collection of measurements that
17+
* which a user might wish to record throughout a migration-assisted operation.
18+
*
19+
* Example measurements include latency, errors, and consistency.
20+
*
21+
* The OpTracker is not expected to be instantiated directly. Consumers should
22+
* instead call {@see \LaunchDarkly\LDClient:migration_variation()} and use the
23+
* returned tracker instance.
24+
*/
25+
class OpTracker
26+
{
27+
private LDContext $context;
28+
private ?Operation $operation = null;
29+
private array $invoked = [];
30+
private ?bool $consistent = null;
31+
private int $consistentRatio = 1;
32+
private array $errors = [];
33+
private array $latencies = [];
34+
35+
public function __construct(
36+
private LoggerInterface $logger,
37+
private string $key,
38+
private ?Impl\Model\FeatureFlag $flag,
39+
LDContext|LDUser $contextOrUser,
40+
private EvaluationDetail $detail,
41+
private Stage $default_stage
42+
) {
43+
$this->context = $contextOrUser instanceof LDUser ? LDContext::fromUser($contextOrUser) : $contextOrUser;
44+
$this->consistentRatio = 1; # TODO(sc-219378): This needs to get set from the flag once we support those fields
45+
}
46+
47+
48+
49+
/**
50+
* Sets the migration related {@see Operation} associated with these tracking measurements.
51+
*/
52+
public function operation(Operation $operation): OpTracker
53+
{
54+
$this->operation = $operation;
55+
return $this;
56+
}
57+
58+
/**
59+
* Allows recording which {@see Origin}s were called during a migration.
60+
*/
61+
public function invoked(Origin $origin): OpTracker
62+
{
63+
$this->invoked[$origin->value] = true;
64+
return $this;
65+
}
66+
67+
68+
/**
69+
* Allows recording the results of a consistency check.
70+
*
71+
* This method accepts a callable which should take no parameters and return
72+
* a single boolean to represent the consistency check results for a read
73+
* operation.
74+
*
75+
* A callable is provided in case sampling rules do not require consistency
76+
* checking to run. In this case, we can avoid the overhead of a function by
77+
* not using the callable.
78+
*
79+
* @param callable $isConsistent Callable that accepts 0 parameters and must return a boolean
80+
*/
81+
public function consistent(callable $isConsistent): OpTracker
82+
{
83+
# TODO(sc-219378): Add sampling support
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. This event
116+
* data can be provided to {@see \LaunchDarkly\LDClient::trackMigrationOp()}
117+
* to relay this metric information upstream to LaunchDarkly services.
118+
*
119+
* @return array<string, mixed>|string
120+
*/
121+
public function build(): array|string
122+
{
123+
if (!$this->operation) {
124+
return "operation not provided";
125+
} elseif (strlen($this->key) === 0) {
126+
return "migration operation cannot contain an empty key";
127+
} elseif (count($this->invoked) === 0) {
128+
return "no origins were invoked";
129+
} elseif (!$this->context->isValid()) {
130+
return "provided context was invalid";
131+
}
132+
133+
$error = $this->checkInvokedConsistency();
134+
if ($error !== null) {
135+
return $error;
136+
}
137+
138+
$event = [
139+
'kind' => 'migration_op',
140+
'creationDate' => Util::currentTimeUnixMillis(),
141+
'contextKeys' => $this->context->getKeys(),
142+
'operation' => $this->operation->value,
143+
'evaluation' => [
144+
'key' => $this->key,
145+
'value' => $this->detail->getValue(),
146+
'default' => $this->default_stage->value,
147+
'reason' => $this->detail->getReason(),
148+
],
149+
150+
'measurements' => [
151+
[
152+
'key' => 'invoked',
153+
'values' => $this->invoked,
154+
]
155+
],
156+
];
157+
158+
if ($this->flag) {
159+
$event['evaluation']['version'] = $this->flag->getVersion();
160+
}
161+
162+
if ($this->detail->getVariationIndex() !== null) {
163+
$event['evaluation']['variation'] = $this->detail->getVariationIndex();
164+
}
165+
166+
if ($this->consistent !== null) {
167+
$measurement = [
168+
'key' => 'consistent',
169+
'value' => $this->consistent,
170+
];
171+
172+
if ($this->consistentRatio !== 1) {
173+
$measurement['samplingRatio'] = $this->consistentRatio;
174+
}
175+
176+
$event['measurements'][] = $measurement;
177+
}
178+
179+
if (count($this->errors)) {
180+
$event['measurements'][] = [
181+
'key' => 'error',
182+
'values' => $this->errors,
183+
];
184+
}
185+
186+
if (count($this->latencies)) {
187+
$event['measurements'][] = [
188+
'key' => 'latency_ms',
189+
'values' => $this->latencies,
190+
];
191+
}
192+
193+
return $event;
194+
}
195+
196+
private function checkInvokedConsistency(): ?string
197+
{
198+
foreach (Origin::cases() as $origin) {
199+
$originValue = $origin->value;
200+
if (isset($this->invoked[$originValue])) {
201+
continue;
202+
}
203+
204+
if (isset($this->latencies[$originValue])) {
205+
return "provided latency for origin {$originValue} without recording invocation";
206+
}
207+
208+
if (isset($this->errors[$originValue])) {
209+
return "provided error for origin {$originValue} without recording invocation";
210+
}
211+
}
212+
213+
if ($this->consistent !== null && count($this->invoked) !== 2) {
214+
return "provided consistency without recording both invocations";
215+
}
216+
217+
return null;
218+
}
219+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace LaunchDarkly\Migrations;
6+
7+
/**
8+
* The operation enum is used to record the type of migration operation that
9+
* occurred.
10+
*/
11+
enum Operation: string
12+
{
13+
/**
14+
* READ represents a read-only operation on an origin of data.
15+
*
16+
* A read operation carries the implication that it can be executed in
17+
* parallel against multiple origins.
18+
*/
19+
case READ = 'read';
20+
21+
/**
22+
* WRITE represents a write operation on an origin of data.
23+
*
24+
* A write operation implies that execution cannot be done in parallel
25+
* against multiple origins.
26+
*/
27+
case WRITE = 'write';
28+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace LaunchDarkly\Migrations;
6+
7+
/**
8+
* The origin enum is used to denote which source of data should be affected
9+
* by a particular operation.
10+
*/
11+
enum Origin: string
12+
{
13+
/**
14+
* The OLD origin is the source of data we are migrating from. When the
15+
* migration is complete, this source of data will be unused.
16+
*/
17+
case OLD = 'old';
18+
19+
/**
20+
* The NEW origin is the source of data we are migrating to. When the
21+
* migration is complete, this source of data will be the source of
22+
* truth.
23+
*/
24+
case NEW = 'new';
25+
}

0 commit comments

Comments
 (0)