Skip to content

Commit 8a5338b

Browse files
Kaustubh22327rohitesh-wingify
authored andcommitted
feat: support for web testing presegmentation
1 parent 303235d commit 8a5338b

11 files changed

Lines changed: 539 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [2.10.0] - 2026-07-04
9+
10+
### Added
11+
12+
- Support for **Web Testing pre-segmentation** in FME: campaign segmentation can use the `campaignVariation` operand. The SDK evaluates it against **`context.platformVariables.webTestingCampaigns`**, a map of Web Testing campaign ID → variation ID (plain object or JSON string). Supported operand values in settings: `122` (user in campaign), `122_2` (exact variation), `122_!1` (in campaign but not variation 1), `!122` (not in campaign).
13+
814
## [2.5.0] - 2026-06-16
915

1016
### Added

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ The following table explains all the parameters in the `context` array:
8989
| `userAgent` | User agent string for identifying the user's browser and operating system. | No | string | `'Mozilla/5.0 ... Safari/537.36'` |
9090
| `ipAddress` | IP address of the user. | No | string | `'1.1.1.1'` |
9191
| `bucketingSeed` | Custom seed for bucketing logic instead of user ID. | No | string | `'custom_seed_value'` |
92+
| `platformVariables`| Optional context payload mapping. Used for Web testing campaigns. | No | array | `['webTestingCampaigns' => '{"122":"1"}']` |
9293

9394
#### Example
9495

@@ -99,9 +100,29 @@ $userContext = [
99100
'userAgent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36',
100101
'ipAddress' => '1.1.1.1',
101102
'bucketingSeed' => 'custom_seed_value', // Optional: overrides userId for bucketing
103+
'platformVariables' => [
104+
// Reference example only:
105+
// In production, fetch campaign assignments using script run in frontend and pass that object to backend as webTestingCampaigns.
106+
'webTestingCampaigns' => '{"122":"1","130":"2"}'
107+
],
102108
];
103109
```
104110

111+
### Web testing pre-segmentation
112+
113+
Server-side flag decisions can align with **Web Testing** (browser) experiments. **Campaign assignments are typically read on the frontend** (for example, Wingify/VWO cookies) and sent to your server; pass them in `platformVariables['webTestingCampaigns']` as a map of **campaign ID -> variation ID** (strings), or as a **JSON string** of that object.
114+
115+
Pre-segment rules in the FME dashboard can use the **`campaignVariation`** operator:
116+
117+
| Operand pattern | Meaning |
118+
| --------------- | ------- |
119+
| `C` | User is in campaign `C` (any variation). |
120+
| `!C` | User is **not** in campaign `C`. |
121+
| `C_V` | User is in campaign `C` with variation `V`. |
122+
| `C_!V` | User is in campaign `C` and assigned variation is **not** `V`. |
123+
124+
Rollout rules evaluate pre-segments from the **first variation** only; A/B testing rules use **campaign-level** segments.
125+
105126
### Basic Feature Flagging
106127

107128
Feature Flags serve as the foundation for all testing, personalization, and rollout rules within FME.

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "wingify/wingify-fme-php-sdk",
3-
"version": "2.5.0",
3+
"version": "2.10.0",
44
"keywords": [
55
"wingify",
66
"fme",

src/Constants/Constants.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ class Constants {
4949
const DEFAULT_EVENTS_PER_REQUEST = 100;
5050
const SDK_NAME = 'wingify-fme-php-sdk';
5151

52-
const SDK_VERSION = '2.5.0';
52+
const SDK_VERSION = '2.10.0';
5353
const AP = 'server';
5454

5555
const SETTINGS = 'settings';

src/Models/User/ContextModel.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ class ContextModel
3434
private $uuid;
3535
private $sessionId;
3636
private $bucketingSeed;
37+
private $platformVariables;
3738

3839
public function modelFromDictionary($context)
3940
{
@@ -42,6 +43,10 @@ public function modelFromDictionary($context)
4243
$this->ipAddress = isset($context['ipAddress']) ? $context['ipAddress'] : null;
4344
$this->bucketingSeed = isset($context['bucketingSeed']) ? $context['bucketingSeed'] : null;
4445

46+
if (isset($context['platformVariables'])) {
47+
$this->platformVariables = $context['platformVariables'];
48+
}
49+
4550
if (isset($context['customVariables'])) {
4651
$this->customVariables = $context['customVariables'];
4752
}
@@ -146,6 +151,16 @@ public function getBucketingSeed()
146151
{
147152
return $this->bucketingSeed;
148153
}
154+
155+
public function getPlatformVariables()
156+
{
157+
return $this->platformVariables;
158+
}
159+
160+
public function setPlatformVariables($platformVariables)
161+
{
162+
$this->platformVariables = $platformVariables;
163+
}
149164
}
150165

151166
?>

src/Packages/SegmentationEvaluator/Enums/SegmentOperatorValueEnum.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,5 +36,6 @@ class SegmentOperatorValueEnum {
3636
const IP = 'ip_address';
3737
const BROWSER_VERSION = 'browser_version';
3838
const OS_VERSION = 'os_version';
39+
const WEB_CAMPAIGN_VARIATION = 'campaignVariation';
3940
}
4041
?>

src/Packages/SegmentationEvaluator/Evaluators/SegmentEvaluator.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ public function isSegmentationValid($dsl, $properties)
7272
return $this->segmentOperandEvaluator->evaluateStringOperandDSL($subDsl, $this->context, SegmentOperatorValueEnum::BROWSER_VERSION);
7373
case SegmentOperatorValueEnum::OS_VERSION:
7474
return $this->segmentOperandEvaluator->evaluateStringOperandDSL($subDsl, $this->context, SegmentOperatorValueEnum::OS_VERSION);
75+
case SegmentOperatorValueEnum::WEB_CAMPAIGN_VARIATION:
76+
return $this->segmentOperandEvaluator->evaluateCampaignVariationDSL($subDsl, $this->context);
7577
default:
7678
return false;
7779
}

src/Packages/SegmentationEvaluator/Evaluators/SegmentOperandEvaluator.php

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,68 @@ public function evaluateUserAgentDSL($dslOperandValue, $context) {
110110
return $this->extractResult($operandTypeAndValue->operandType, $processedValues->operandValue, $tagValue);
111111
}
112112

113+
/**
114+
* Evaluates Web Testing pre-segmentation against `context.platformVariables.webTestingCampaigns`.
115+
* Operand: "C" (in Campaign, any variation), "C_V", "C_!V", "!C" (not in Campaign C).
116+
*/
117+
public function evaluateCampaignVariationDSL($campaignVariationOperand, $context) {
118+
// Settings JSON often deserializes campaign ids as numbers; coerce before matching DSL tokens.
119+
$operandString = null;
120+
if (is_numeric($campaignVariationOperand) && is_finite($campaignVariationOperand)) {
121+
$operandString = strval($campaignVariationOperand);
122+
} elseif (is_string($campaignVariationOperand)) {
123+
$operandString = $campaignVariationOperand;
124+
}
125+
126+
if ($operandString === null) {
127+
$type = strtolower(gettype($campaignVariationOperand));
128+
$this->serviceContainer->getLoggerService()->error(
129+
'INVALID_WEB_TESTING_CAMPAIGN_VARIATION_OPERAND_TYPE',
130+
['type' => $type, 'an' => ApiEnum::GET_FLAG, 'uuid' => $context->getUUID(), 'sId' => $context->getSessionId()]
131+
);
132+
return false;
133+
}
134+
135+
// Empty operand is invalid.
136+
if (strlen($operandString) === 0) {
137+
$this->serviceContainer->getLoggerService()->error(
138+
'INVALID_WEB_TESTING_CAMPAIGN_VARIATION_OPERAND_EMPTY',
139+
['an' => ApiEnum::GET_FLAG, 'uuid' => $context->getUUID(), 'sId' => $context->getSessionId()]
140+
);
141+
return false;
142+
}
143+
144+
$trimmedCampaignVariationOperand = trim($operandString);
145+
// All spaces is invalid.
146+
if (strlen($trimmedCampaignVariationOperand) === 0) {
147+
$this->serviceContainer->getLoggerService()->error(
148+
'INVALID_WEB_TESTING_CAMPAIGN_VARIATION_OPERAND_EMPTY',
149+
['an' => ApiEnum::GET_FLAG, 'uuid' => $context->getUUID(), 'sId' => $context->getSessionId()]
150+
);
151+
return false;
152+
}
153+
154+
// Parse the campaigns from the context.
155+
$assignedVariationsByCampaignId = \wingify\Packages\SegmentationEvaluator\Utils\WebTestingSegmentUtil::parseWebTestingCampaignsFromContext($context, $this->serviceContainer);
156+
$evaluationResult = \wingify\Packages\SegmentationEvaluator\Utils\WebTestingSegmentUtil::evaluateWebTestingCampaignVariation(
157+
$trimmedCampaignVariationOperand,
158+
$assignedVariationsByCampaignId
159+
);
160+
161+
$result = $evaluationResult['result'];
162+
$invalidFormat = $evaluationResult['invalidFormat'];
163+
164+
// Invalid format of the operand.
165+
if ($invalidFormat) {
166+
$this->serviceContainer->getLoggerService()->error(
167+
'INVALID_WEB_TESTING_CAMPAIGN_VARIATION_OPERAND_FORMAT',
168+
['operand' => $trimmedCampaignVariationOperand, 'an' => ApiEnum::GET_FLAG, 'uuid' => $context->getUUID(), 'sId' => $context->getSessionId()]
169+
);
170+
}
171+
172+
return $result;
173+
}
174+
113175
/**
114176
* Evaluates a given string tag value against a DSL operand value.
115177
*
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
<?php
2+
3+
/**
4+
* Copyright 2024-2026 Wingify Software Pvt. Ltd.
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
namespace wingify\Packages\SegmentationEvaluator\Utils;
20+
21+
use wingify\Models\User\ContextModel;
22+
use wingify\Services\ServiceContainer;
23+
use wingify\Enums\ApiEnum;
24+
use wingify\Utils\DataTypeUtil;
25+
26+
class WebTestingSegmentUtil {
27+
/**
28+
* Normalizes Web Testing campaign map keys and variation values to strings.
29+
* @param array|object $rawAssignments - The raw assignments map from the context.
30+
* @return array - The normalized assignments map with campaignId as key and variationId as value.
31+
*/
32+
public static function normalizeWebTestingCampaignsMap($rawAssignments): array {
33+
// Turn the raw assignments map into a simple string map for regex matching.
34+
$campaignIdToVariationId = [];
35+
$iterableAssignments = is_object($rawAssignments) ? get_object_vars($rawAssignments) : $rawAssignments;
36+
37+
foreach ($iterableAssignments as $campaignId => $assignedVariationId) {
38+
if (
39+
!is_null($assignedVariationId) &&
40+
strlen((string)$campaignId) > 0
41+
// Ignore empty keys; null/undefined variations mean nothing assigned for that id.
42+
) {
43+
$campaignIdToVariationId[(string)$campaignId] = (string)$assignedVariationId;
44+
}
45+
}
46+
return $campaignIdToVariationId;
47+
}
48+
49+
/**
50+
* Parses `context.platformVariables.webTestingCampaigns` (JSON string or plain object).
51+
*/
52+
public static function parseWebTestingCampaignsFromContext(
53+
ContextModel $context,
54+
ServiceContainer $serviceContainer
55+
): ?array {
56+
$platformVariables = $context->getPlatformVariables();
57+
$webTestingCampaignsInput = null;
58+
if (is_array($platformVariables) && isset($platformVariables['webTestingCampaigns'])) {
59+
$webTestingCampaignsInput = $platformVariables['webTestingCampaigns'];
60+
} elseif (is_object($platformVariables) && isset($platformVariables->webTestingCampaigns)) {
61+
$webTestingCampaignsInput = $platformVariables->webTestingCampaigns;
62+
}
63+
64+
// No payload from the integration means empty assignments map.
65+
if (is_null($webTestingCampaignsInput)) {
66+
return null;
67+
}
68+
69+
// SDK already forwarded a plain campaignId -> variationId object.
70+
if (is_array($webTestingCampaignsInput) || is_object($webTestingCampaignsInput)) {
71+
return self::normalizeWebTestingCampaignsMap($webTestingCampaignsInput);
72+
}
73+
74+
// Some stacks pass JSON text (cookie, SSR prop, tag); parse it only if it's an object.
75+
if (DataTypeUtil::isString($webTestingCampaignsInput)) {
76+
$trimmedWebTestingCampaignsJson = trim($webTestingCampaignsInput);
77+
if ($trimmedWebTestingCampaignsJson === '') {
78+
// Empty JSON string is invalid.
79+
return null;
80+
}
81+
try {
82+
// extract all "key": tokens and check for duplicates before parsing swallows them
83+
preg_match_all('/"([^"\\\\]*)"\s*:/', $trimmedWebTestingCampaignsJson, $matches);
84+
if (!empty($matches[1])) {
85+
$campaignIds = $matches[1];
86+
$hasDuplicateCampaignId = count($campaignIds) !== count(array_unique($campaignIds));
87+
if ($hasDuplicateCampaignId) {
88+
$serviceContainer
89+
->getLoggerService()
90+
->error(
91+
'INVALID_WEB_TESTING_CAMPAIGNS_DUPLICATE_KEY',
92+
['an' => ApiEnum::GET_FLAG, 'uuid' => $context->getUUID(), 'sId' => $context->getSessionId()]
93+
);
94+
}
95+
}
96+
// Parse the JSON string into an object.
97+
$parsedAssignments = json_decode($trimmedWebTestingCampaignsJson, true);
98+
if (json_last_error() === JSON_ERROR_NONE && (is_object(json_decode($trimmedWebTestingCampaignsJson)) || is_array($parsedAssignments))) {
99+
return self::normalizeWebTestingCampaignsMap($parsedAssignments);
100+
}
101+
// Parsed fine but it's an array/string/etc. Invalid shape.
102+
$serviceContainer
103+
->getLoggerService()
104+
->error(
105+
'INVALID_WEB_TESTING_CAMPAIGNS_JSON',
106+
['an' => ApiEnum::GET_FLAG, 'uuid' => $context->getUUID(), 'sId' => $context->getSessionId()]
107+
);
108+
} catch (\Exception $e) {
109+
// Malformed JSON; treat like missing assignments.
110+
$serviceContainer
111+
->getLoggerService()
112+
->error(
113+
'INVALID_WEB_TESTING_CAMPAIGNS_JSON',
114+
['an' => ApiEnum::GET_FLAG, 'uuid' => $context->getUUID(), 'sId' => $context->getSessionId()]
115+
);
116+
}
117+
return null;
118+
}
119+
120+
// Booleans/numbers/other odd types are invalid.
121+
if (!is_null($webTestingCampaignsInput)) {
122+
$kind = strtolower(gettype($webTestingCampaignsInput));
123+
$serviceContainer
124+
->getLoggerService()
125+
->error(
126+
'INVALID_WEB_TESTING_CAMPAIGNS_TYPE',
127+
['kind' => $kind, 'an' => ApiEnum::GET_FLAG, 'uuid' => $context->getUUID(), 'sId' => $context->getSessionId()]
128+
);
129+
}
130+
return null;
131+
}
132+
133+
/**
134+
* Evaluates campaignVariation operand encoding:
135+
* - "!C" — user is not in campaign C (no entry in map)
136+
* - "C_!V" — user is in campaign C and assigned variation is not V
137+
* - "C_V" — user is in campaign C with variation V
138+
* - "C" (digits only) — user is in campaign C (any variation)
139+
*/
140+
public static function evaluateWebTestingCampaignVariation(
141+
string $campaignVariationOperand,
142+
?array $assignedVariationsByCampaignId
143+
): array {
144+
// Null means empty assignments map.
145+
$assignments = $assignedVariationsByCampaignId ?? [];
146+
147+
// !123 — user should not be in campaign 123.
148+
if (preg_match('/^!(\d+)$/', $campaignVariationOperand, $match)) {
149+
$campaignId = $match[1];
150+
return ['result' => !array_key_exists($campaignId, $assignments), 'invalidFormat' => false];
151+
}
152+
153+
// 123_!4 — in campaign 123 but not the variation 4.
154+
if (preg_match('/^(\d+)_!(\d+)$/', $campaignVariationOperand, $match)) {
155+
$campaignId = $match[1];
156+
$variationId = $match[2];
157+
if (!array_key_exists($campaignId, $assignments)) {
158+
return ['result' => false, 'invalidFormat' => false];
159+
}
160+
return ['result' => $assignments[$campaignId] !== $variationId, 'invalidFormat' => false];
161+
}
162+
163+
// 123_4 — must be exactly that campaign and variation.
164+
if (preg_match('/^(\d+)_(\d+)$/', $campaignVariationOperand, $match)) {
165+
$campaignId = $match[1];
166+
$variationId = $match[2];
167+
if (!array_key_exists($campaignId, $assignments)) {
168+
return ['result' => false, 'invalidFormat' => false];
169+
}
170+
return ['result' => $assignments[$campaignId] === $variationId, 'invalidFormat' => false];
171+
}
172+
173+
// 123 — in the campaign, any variation counts.
174+
if (preg_match('/^(\d+)$/', $campaignVariationOperand, $match)) {
175+
$campaignId = $match[1];
176+
return ['result' => array_key_exists($campaignId, $assignments), 'invalidFormat' => false];
177+
}
178+
179+
// Invalid format.
180+
return ['result' => false, 'invalidFormat' => true];
181+
}
182+
}

0 commit comments

Comments
 (0)