Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
53a0b8c
wf(wf-phpsdk-qs01): [PHP-1] import cross-SDK golden-vector fixture
abbaseya Jul 3, 2026
11649f6
wf(wf-phpsdk-qs01): [PHP-2] anchored bucketing tests (RED)
abbaseya Jul 3, 2026
1f53b15
wf(wf-phpsdk-qs01): [PHP-2] anchored bucketing layout impl (GREEN)
abbaseya Jul 3, 2026
1c43e3e
wf(wf-phpsdk-qs01): review R1 — dedupe AC test literals (SonarQube 3%…
abbaseya Jul 3, 2026
cdafc96
feat(bucketing)!: anchored bucketing layout (contract v12)
abbaseya Jul 3, 2026
b044c4f
refactor(bucketing): extract shared active-variation predicate in Dat…
abbaseya Jul 3, 2026
dd8595b
wf(wf-phpsdk-qs02): [PHP-1] debugToken — tests (RED)
abbaseya Jul 6, 2026
f9ce02b
wf(wf-phpsdk-qs02): [PHP-1] debugToken — implementation (GREEN)
abbaseya Jul 6, 2026
a371eb1
wf(wf-phpsdk-qs02): [PHP-2] preview input — tests (RED)
abbaseya Jul 6, 2026
551229f
wf(wf-phpsdk-qs02): [PHP-2] preview input — implementation (GREEN)
abbaseya Jul 6, 2026
6703e89
wf(wf-phpsdk-qs02): [PHP-2] decision-audit remediation — zero-trace f…
abbaseya Jul 6, 2026
b974254
wf(wf-phpsdk-qs02): review R1 — redact debug_token in PSR-18 exceptio…
abbaseya Jul 6, 2026
eba310b
wf(wf-phpsdk-qs02): docs-sync — document debugToken + experiment preview
abbaseya Jul 6, 2026
cd00939
fix(preview): encoding-agnostic debug_token redaction + guard null pr…
abbaseya Jul 7, 2026
145f7bb
Merge pull request #50 from convertcom/feat/anchored-bucketing-layout
abbaseya Jul 7, 2026
98dde09
wf(wf-phpsdk-qs03): [PHP-1] enums — exclusion warn constant + RuleTyp…
abbaseya Jul 8, 2026
a89f312
wf(wf-phpsdk-qs03): [PHP-2] shared 8-row fixture + exp-a/exp-b config
abbaseya Jul 8, 2026
86d4b66
wf(wf-phpsdk-qs03): [PHP-3] tests (RED) — seam pins for AC1-AC6,AC8
abbaseya Jul 8, 2026
2861073
wf(wf-phpsdk-qs03): [PHP-3] implementation (GREEN) — mutual-exclusion…
abbaseya Jul 8, 2026
7cac865
wf(wf-phpsdk-qs03): [PHP-4] AC7 generic-rule regression lock
abbaseya Jul 8, 2026
49cf5b7
wf(wf-phpsdk-qs03): decision_audit R1 fix — relocate warn constant to…
abbaseya Jul 8, 2026
50bc57b
wf(wf-phpsdk-qs03): decision_audit R2 fix — sweep stale ErrorMessages…
abbaseya Jul 8, 2026
ad15917
fix(qs-03): coerce null visitorProperties + is_array/id guards in mut…
abbaseya Jul 8, 2026
e62d2b6
Merge pull request #51 from convertcom/feat/experiment-preview
abbaseya Jul 15, 2026
23301f3
Merge pull request #52 from convertcom/feat/mutual-exclusion-rule
abbaseya Jul 16, 2026
8070ea4
feat(demo): wire experiment preview links + debugToken into Laravel demo
abbaseya Jul 18, 2026
875efe0
fix(preview): zero-trace in-process events (bucketing/conversion/loca…
abbaseya Jul 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ Bucket visitors into experiment variations, resolve feature flags with typed var
- [Visitor Context](#visitor-context)
- [Experience Bucketing](#experience-bucketing)
- [Feature Flags](#feature-flags)
- [Experiment Preview](#experiment-preview)
- [Conversion Tracking](#conversion-tracking)
- [Revenue Reporting](#revenue-reporting)
- [Force Multiple Transactions](#force-multiple-transactions)
Expand Down Expand Up @@ -154,6 +155,24 @@ $sdk = ConvertSDK::create([

Pass any PSR-16 `CacheInterface`. When omitted, an in-memory `ArrayCache` is used (no persistence between requests).

### QA debug token

For QA and preview scenarios where you need the freshest possible config, pass a `debugToken`:

```php
$sdk = ConvertSDK::create([
'sdkKey' => 'your-sdk-key',
'debugToken' => 'your-qa-debug-token',
]);
```

When set, the SDK:

- appends `debug_token=<token>` and forces `_conv_low_cache=1` on every config fetch, regardless of the project's cache level;
- bypasses the PSR-16 config cache entirely — every request fetches config live from origin (the config cache entry is neither read nor written);
- redacts the token from all log output (including PSR-18 client exception messages);
- never sends the token to the tracking endpoint.

**Important:** The PSR-16 cache also serves as the visitor data store. When you provide a persistent cache (Redis, Memcached, filesystem), the SDK automatically persists visitor bucketing decisions across HTTP requests. This enables conversion tracking in later requests to be correctly attributed to experiment variations. See [Data Persistence](#data-persistence) for details.

### Full configuration options
Expand All @@ -167,6 +186,7 @@ $sdk = ConvertSDK::create([
'dataStore' => $customStore, // Custom data store (overrides cache for visitor data)
'dataRefreshInterval' => 300000, // Config cache TTL in milliseconds (default: 300000 = 5 min)
'environment' => 'production', // Environment targeting
'debugToken' => 'qa-debug-token', // QA/preview: bypass config cache + force fresh fetch (see QA debug token)
]);
```

Expand Down Expand Up @@ -341,6 +361,54 @@ foreach ($features as $feature) {

**Returns:** `BucketedFeature[]` — an array of all resolved features.

## Experiment Preview

Force a visitor context to decide a specific variation of a specific experience, bypassing every normal gate — audiences, segments, locations, environment, experience/variation status, traffic allocation, stored decisions, and the bucketing hash. This is how you render a QA/preview of a variation that a real visitor would not otherwise be bucketed into.

```php
$context = $sdk->createContext('qa-visitor');

// Force the experience whose id is 100200 to decide variation 300400
$context->setPreview('100200', '300400');

// When 'homepage-redesign' is the key of experience 100200, the forced
// variation is returned regardless of targeting or bucketing
$variation = $context->runExperience('homepage-redesign');
```

**Parameters:**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `$experienceId` | `string` | Yes | The experience id (numeric string) to preview. |
| `$variationId` | `string` | Yes | The variation id (numeric string) to force. |

**Returns:** `void`

### Zero-trace guarantee

Once a preview target resolves successfully, the context becomes **zero-trace for its entire lifetime**: no tracking events are sent and no visitor state is persisted for **any** experience, feature, or conversion run through that context — not just the previewed one. Preview never pollutes real experiment data, including on the shutdown flush.

### Inert on bad input

If the experience or variation id cannot be resolved (unknown experience, unknown variation), `setPreview()` is a no-op and the context behaves fully normally — normal bucketing and tracking resume. A preview target absent from the current config is fetched on demand via a single-experience config request (`?exp=`) and memoized for 60 seconds.

### Preview links

The canonical preview link format is `convert_preview={experienceId}.{variationId}`. Your application extracts the raw query-string value and parses it with `PreviewParam` — the SDK never reads request superglobals directly:

```php
use ConvertSdk\Preview\PreviewParam;

$parsed = PreviewParam::parse($_GET['convert_preview'] ?? '');

if ($parsed !== null) {
$context->setPreview($parsed['experienceId'], $parsed['variationId']);
}
```

`PreviewParam::parse()` returns `['experienceId' => string, 'variationId' => string]` for a well-formed value, or `null` when the value is malformed.

## Conversion Tracking

Track a goal conversion for the current visitor:
Expand Down
3 changes: 3 additions & 0 deletions demo/laravel/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,6 @@ CONVERT_FEATURE_KEY_PRICING=feature-5
CONVERT_FEATURE_KEY_STATS=feature-4
CONVERT_GOAL_KEY=button-primary-click
CONVERT_SEGMENT_KEY=test-segment-1
# Optional QA debug token (24h TTL) — widens the fetched config to draft/paused
# statuses and disables the SDK config cache while set. Redacted from logs.
# CONVERT_DEBUG_TOKEN=
46 changes: 41 additions & 5 deletions demo/laravel/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,38 @@ Visit [http://localhost:8080](http://localhost:8080).
| `/statistics` | Multiple experiments and feature flag (different key) |
| `POST /api/buy` | Conversion tracking (`trackConversion`) with goal data (amount, products count) |

## Preview Links & QA

The demo wires up the two SDK QA/preview capabilities so a stakeholder or tester can exercise them without touching code.

### Preview links (`?convert_preview=`)

A preview link renders **one specific variation server-side** — bypassing bucketing, audiences, segments, locations, the environment check, experience/variation status, and stored decisions — with **zero tracking events** and **zero visitor-state persistence** (cache/dataStore) for that request.

Append `?convert_preview={experienceId}.{variationId}` to **any** demo page, e.g.:

```
http://localhost:8000/events?convert_preview=123456.789012
```

Where `experienceId`/`variationId` are the **numeric** ids of the experience/variation to force — copy them from the Convert app's per-variation "Copy preview link" action, or from the ids of the experience already configured via `CONVERT_EXPERIENCE_KEY` in `config/convert.php`.

Preview auto-fetches the target experience via the serving `?exp=` param when it isn't already present in the loaded config, so you can preview a **draft or paused** experience with no token needed at all.

To confirm zero-trace behavior:
- Watch the app logs for `[ConvertSDK] Preview active — experienceId=... variationId=...` when a preview request comes in.
- No request reaches the tracking endpoint and no cache/dataStore entry is written for that request — every other page you load in the same session still buckets and persists normally, so you can compare side-by-side.

### `debugToken` — QA config access

Set `CONVERT_DEBUG_TOKEN` in `.env` to have every config fetch pull the **full, fresh** config — including draft and paused experiences — with the SDK's config cache disabled for as long as the token is set (every request fetches live from origin). The token has a 24-hour TTL on the backend, is redacted from all SDK logs, and is never sent to the tracking endpoint.

```env
CONVERT_DEBUG_TOKEN=your-qa-debug-token
```

Generate a token from the Convert app for the project configured via `CONVERT_SDK_KEY`. Leave it unset for normal (production-like) demo behavior.

## Configuration

Override the default Convert project keys via `.env`:
Expand All @@ -48,17 +80,21 @@ CONVERT_FEATURE_KEY_PRICING=feature-5
CONVERT_FEATURE_KEY_STATS=feature-4
CONVERT_GOAL_KEY=button-primary-click
CONVERT_SEGMENT_KEY=test-segment-1
CONVERT_DEBUG_TOKEN=
```

See [Preview Links & QA](#preview-links--qa) above for what `CONVERT_DEBUG_TOKEN` does.

## Architecture

```
Request
→ ConvertContext middleware
├ Read/generate userId cookie (1-hour expiry)
├ Read/generate userId cookie (1-hour expiry, skipped while previewing)
├ Resolve SDK singleton (ConvertServiceProvider)
├ Create visitor context with attributes
└ Set default segments
├ Set default segments
└ Parse ?convert_preview= and setPreview() when present
→ Controller
├ runExperience / runExperiences / runFeature
├ setCustomSegments / trackConversion
Expand All @@ -75,10 +111,10 @@ grep -r '\[ConvertSDK\]' app/
```

**Key files:**
- `app/Providers/ConvertServiceProvider.php` — SDK singleton with PSR-16 filesystem cache
- `app/Http/Middleware/ConvertContext.php` — Per-request visitor context creation
- `app/Providers/ConvertServiceProvider.php` — SDK singleton with PSR-16 filesystem cache; conditionally wires `debugToken`
- `app/Http/Middleware/ConvertContext.php` — Per-request visitor context creation; parses `?convert_preview=` via `PreviewParam::parse()` and calls `$context->setPreview()`
- `app/Http/Controllers/` — SDK method calls per route
- `config/convert.php` — All Convert keys (env-configurable)
- `config/convert.php` — All Convert keys (env-configurable), including `debug_token`

## Links

Expand Down
33 changes: 31 additions & 2 deletions demo/laravel/app/Http/Middleware/ConvertContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace App\Http\Middleware;

use Closure;
use ConvertSdk\Preview\PreviewParam;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
Expand All @@ -20,6 +21,8 @@ public function handle(Request $request, Closure $next): Response
$newVisitor = true;
}

$previewActive = false;

// [ConvertSDK] Resolve SDK singleton from container
try {
$sdk = app('convert.sdk');
Expand All @@ -31,6 +34,28 @@ public function handle(Request $request, Closure $next): Response
if ($context) {
// [ConvertSDK] Set default segments matching JS demo
$context->setDefaultSegments(['country' => 'US']);

// [ConvertSDK] qs-16 preview link — ?convert_preview={experienceId}.{variationId}
// forces that exact variation server-side with zero tracking/persistence for
// the rest of this context's lifetime. Inert (no-op) on a missing/malformed
// param — PreviewParam::parse() returns null and we simply skip setPreview().
$previewParam = $request->query('convert_preview');

if (is_string($previewParam)) {
$parsed = PreviewParam::parse($previewParam);

if ($parsed !== null) {
$context->setPreview($parsed['experienceId'], $parsed['variationId']);
$previewActive = true;

Log::info(sprintf(
'[ConvertSDK] Preview active — experienceId=%s variationId=%s (zero-trace context)',
$parsed['experienceId'],
$parsed['variationId']
));
}
}

$request->attributes->set('sdkContext', $context);
}
} else {
Expand All @@ -42,8 +67,12 @@ public function handle(Request $request, Closure $next): Response

$response = $next($request);

// Set visitor ID cookie on response if newly generated (1-hour expiry)
if ($newVisitor) {
// Set visitor ID cookie on response if newly generated (1-hour expiry).
// [ConvertSDK] Skip the cookie write while previewing so a stakeholder preview
// request stays stateless on the demo side too — the SDK-level zero-trace
// guarantee already covers cache/dataStore; this just avoids issuing a new
// visitor identity cookie for a request that was never really "visited".
if ($newVisitor && !$previewActive) {
$response->headers->setCookie(
cookie('userId', $userId, 60) // 60 minutes
);
Expand Down
14 changes: 12 additions & 2 deletions demo/laravel/app/Providers/ConvertServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,25 @@ public function register(): void
directory: storage_path('framework/cache/convert'),
));

return ConvertSDK::create([
$sdkConfig = [
'sdkKey' => config('convert.sdk_key'), // [ConvertSDK]
'cache' => $cache, // [ConvertSDK]
'environment' => config('convert.environment'), // [ConvertSDK]
'logger' => [ // [ConvertSDK]
'logLevel' => LogLevel::Trace,
'customLoggers' => [$app->make(LoggerInterface::class)],
],
]);
];

// [ConvertSDK] qs-16 QA capability — only pass debugToken when a non-empty
// token is configured; passing null/empty would needlessly disable the
// config cache (Core::fetchConfig() treats any non-empty string as "skip cache").
$debugToken = config('convert.debug_token');
if (is_string($debugToken) && $debugToken !== '') {
$sdkConfig['debugToken'] = $debugToken; // [ConvertSDK]
}

return ConvertSDK::create($sdkConfig);
});
}
}
1 change: 1 addition & 0 deletions demo/laravel/config/convert.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@
'feature_key_stats' => env('CONVERT_FEATURE_KEY_STATS', 'feature-4'),
'goal_key' => env('CONVERT_GOAL_KEY', 'button-primary-click'),
'segment_key' => env('CONVERT_SEGMENT_KEY', 'test-segment-1'),
'debug_token' => env('CONVERT_DEBUG_TOKEN'),
];
Loading
Loading