Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 17 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ Default is `OBSERVE`.

## Analytics

When enabled, the SDK emits **one analytics event per request** to the Supertab Connect relay at `{baseUrl}/ingest/events`, carrying bot-classification signals (user agent, client IP, request metadata, and the verification/enforcement decision). It is **off by default** — enable it with `analyticsEnabled: true`:
When enabled, the SDK emits **one analytics event per request** to the Supertab Connect relay at `{analyticsBaseUrl}/ingest/events` — defaulting to the dedicated ingest service (`https://ingest-connect.supertab.co`) — carrying bot-classification signals (user agent, client IP, request metadata, and the verification/enforcement decision). It is **off by default** — enable it with `analyticsEnabled: true`:

```php
$connect = new SupertabConnect(
Expand All @@ -95,6 +95,7 @@ $connect = new SupertabConnect(

- **Fail-open.** Emission never throws or alters request handling; errors are swallowed and the relay POST uses a short timeout.
- **Isolated from billing.** Analytics goes only to `/ingest/events`; the billing `/events` path is untouched.
- **Independent of `setBaseUrl`.** The analytics host defaults to the ingest service and is separate from the API base URL (token/JWKS/verify). Point it at another environment (or localhost) with `setAnalyticsBaseUrl()`, or per-instance via the `analyticsBaseUrl` constructor option.

Events are emitted with `schema_version: 2` (Capture v2), adding spoof-detection signals read from the request: `Sec-Fetch-*` and client hints (`Sec-CH-UA*`), `accept`, `host`, cookie presence, and the stripped/sorted `header_names` set, plus query-shape signals (`query_length`, `query_param_count`, `query_suspicious`). The raw query string is never stored. CDN-only transport signals (TLS version/cipher, JA4, verified-bot category, AS organization, …) are emitted as `null` at a PHP origin unless injected explicitly via `RequestContext`'s `cdnSignals` (see below).

Expand All @@ -121,7 +122,7 @@ $connect = new SupertabConnect(

// The scheduled job runs in a cron/loopback worker; the POST is plain synchronous there.
add_action('supertab_connect_emit_analytics', function (array $payload) use ($apiKey) {
(new HttpAnalyticsTransport($apiKey, SupertabConnect::getBaseUrl(), new HttpClient))
(new HttpAnalyticsTransport($apiKey, SupertabConnect::getAnalyticsBaseUrl(), new HttpClient))
->emit(AnalyticsEvent::fromArray($payload));
});
```
Expand All @@ -142,8 +143,9 @@ Creates a singleton instance. Returns the existing instance if one already exist
| `baseUrl` | `?string` | No | `null` | Set the global default base URL (same as `setBaseUrl()`) |
| `httpClient` | `?HttpClientInterface` | No | `null` | Inject a custom HTTP client (defaults to built-in cURL client) |
| `botDetector` | `?BotDetectorInterface` | No | `null` | Inject a custom bot detector (defaults to `DefaultBotDetector`) |
| `analyticsEnabled` | `bool` | No | `false` | Emit one relay analytics event per request to `{baseUrl}/ingest/events` (see [Analytics](#analytics)) |
| `analyticsEnabled` | `bool` | No | `false` | Emit one relay analytics event per request to `{analyticsBaseUrl}/ingest/events` (see [Analytics](#analytics)) |
| `analyticsTransport` | `?AnalyticsTransportInterface` | No | `null` | Route analytics through a custom delivery path (e.g. a job queue). Used as-is when provided — bypasses the default deferred HTTP transport (see [Delivery](#delivery)) |
| `analyticsBaseUrl` | `?string` | No | `null` | Base URL of the analytics ingest service (resolves to `https://ingest-connect.supertab.co` by default). Independent of `baseUrl`/`setBaseUrl()` (token/JWKS/verify). Also settable globally via `setAnalyticsBaseUrl()`; the per-instance option wins |

### `handleRequest(?RequestContext $context): HandlerResult`

Expand Down Expand Up @@ -305,6 +307,18 @@ SupertabConnect::setBaseUrl('https://api-connect.sbx.supertab.co');

Returns the current global default base URL.

### `SupertabConnect::setAnalyticsBaseUrl()` (static)

Sets the global base URL of the analytics ingest relay (default `https://ingest-connect.supertab.co`). Independent of `setBaseUrl()` — token/JWKS/verify traffic is unaffected.

```php
SupertabConnect::setAnalyticsBaseUrl('https://ingest-connect.sbx.supertab.co');
```

### `SupertabConnect::getAnalyticsBaseUrl()` (static)

Returns the current base URL of the analytics ingest relay.

### `SupertabConnect::resetInstance()` (static)

Clears the singleton instance, allowing a new one to be created with different configuration.
Expand Down
38 changes: 36 additions & 2 deletions src/SupertabConnect.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ final class SupertabConnect

private static string $baseUrl = 'https://api-connect.supertab.co';

/**
* Analytics is served by the dedicated ingest service, not the API host.
* Kept as a separate static (mirroring $baseUrl/setBaseUrl) so the relay
* can be pointed at a different host — or at localhost in dev — without
* moving token/JWKS/verify traffic.
*/
private static string $analyticsBaseUrl = 'https://ingest-connect.supertab.co';

private static ?self $instance = null;

private readonly LicenseTokenVerifier $verifier;
Expand Down Expand Up @@ -70,6 +78,7 @@ public function __construct(
?CacheInterface $cache = null,
bool $analyticsEnabled = false,
?AnalyticsTransportInterface $analyticsTransport = null,
?string $analyticsBaseUrl = null,
) {
if ($this->apiKey === '') {
throw new \InvalidArgumentException('Missing required configuration: apiKey is required');
Expand Down Expand Up @@ -104,7 +113,12 @@ public function __construct(
$this->eventRecorder = new EventRecorder($this->apiKey, self::$baseUrl, $client, $this->debug);
$this->botDetector = $botDetector ?? null;
$this->analyticsEventFactory = new AnalyticsEventFactory;
$this->analyticsTransport = $this->buildAnalyticsTransport($analyticsTransport, $analyticsEnabled, $httpClient);
$this->analyticsTransport = $this->buildAnalyticsTransport(
$analyticsTransport,
$analyticsEnabled,
$httpClient,
$analyticsBaseUrl !== null ? rtrim($analyticsBaseUrl, '/') : self::$analyticsBaseUrl,
);

self::$instance = $this;
}
Expand Down Expand Up @@ -134,6 +148,7 @@ private function buildAnalyticsTransport(
?AnalyticsTransportInterface $injected,
bool $analyticsEnabled,
?HttpClientInterface $httpClient,
string $analyticsBaseUrl,
): AnalyticsTransportInterface {
if ($injected !== null) {
return $injected;
Expand All @@ -152,7 +167,7 @@ private function buildAnalyticsTransport(
return new DeferredAnalyticsTransport(
new HttpAnalyticsTransport(
$this->apiKey,
self::$baseUrl,
$analyticsBaseUrl,
$httpClient ?? new HttpClient(self::ANALYTICS_TIMEOUT_SECONDS),
$this->debug,
),
Expand Down Expand Up @@ -202,6 +217,25 @@ public static function getBaseUrl(): string
return self::$baseUrl;
}

/**
* Override the base URL of the analytics ingest relay (e.g. for a non-prod
* environment or local development). Independent of setBaseUrl —
* token/JWKS/verify traffic is unaffected. Can also be set per-instance via
* the `analyticsBaseUrl` constructor option, which takes precedence.
*/
public static function setAnalyticsBaseUrl(string $url): void
{
self::$analyticsBaseUrl = rtrim($url, '/');
}

/**
* Get the current base URL of the analytics ingest relay.
*/
public static function getAnalyticsBaseUrl(): string
{
return self::$analyticsBaseUrl;
}

/**
* Pure token verification — verifies a license token without recording any events.
* Does not require a SupertabConnect instance.
Expand Down
122 changes: 122 additions & 0 deletions tests/SupertabConnectAnalyticsBaseUrlTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
<?php

declare(strict_types=1);

namespace Supertab\Connect\Tests;

use PHPUnit\Framework\TestCase;
use ReflectionProperty;
use Supertab\Connect\Analytics\DeferredAnalyticsTransport;
use Supertab\Connect\Analytics\HttpAnalyticsTransport;
use Supertab\Connect\SupertabConnect;

/**
* Analytics base URL resolution — the relay targets the dedicated ingest
* service by default, independent of the API base URL used for token
* acquisition / JWKS / verification. Mirrors the TypeScript SDK's
* "analytics base URL resolution" suite.
*/
final class SupertabConnectAnalyticsBaseUrlTest extends TestCase
{
private const DEFAULT_INGEST = 'https://ingest-connect.supertab.co';

protected function setUp(): void
{
SupertabConnect::resetInstance();
SupertabConnect::setBaseUrl('https://api-connect.supertab.co');
// Reset to the class's declared default (not the DEFAULT_INGEST constant),
// so the defaults test genuinely exercises the declared value and a changed
// default fails against the constant instead of being masked by setUp.
SupertabConnect::setAnalyticsBaseUrl($this->declaredDefaultAnalyticsBaseUrl());
}

protected function tearDown(): void
{
SupertabConnect::resetInstance();
SupertabConnect::setBaseUrl('https://api-connect.supertab.co');
SupertabConnect::setAnalyticsBaseUrl($this->declaredDefaultAnalyticsBaseUrl());
}

private function declaredDefaultAnalyticsBaseUrl(): string
{
return (new ReflectionProperty(SupertabConnect::class, 'analyticsBaseUrl'))->getDefaultValue();
}

/**
* Extract the base URL wired into the default HTTP analytics transport
* (unwrapping the deferred decorator).
*/
private function relayBaseUrlOf(SupertabConnect $stc): string
{
$transport = (new ReflectionProperty(SupertabConnect::class, 'analyticsTransport'))->getValue($stc);
$this->assertInstanceOf(DeferredAnalyticsTransport::class, $transport);

$inner = (new ReflectionProperty(DeferredAnalyticsTransport::class, 'inner'))->getValue($transport);
$this->assertInstanceOf(HttpAnalyticsTransport::class, $inner);

return (new ReflectionProperty(HttpAnalyticsTransport::class, 'baseUrl'))->getValue($inner);
}

public function test_defaults_analytics_relay_to_ingest_host(): void
{
$stc = new SupertabConnect(apiKey: 'k', analyticsEnabled: true);

$this->assertSame(self::DEFAULT_INGEST, $this->relayBaseUrlOf($stc));
}
Comment thread
tomasstark marked this conversation as resolved.

public function test_constructor_analytics_base_url_overrides_default_host(): void
{
$stc = new SupertabConnect(
apiKey: 'k',
analyticsEnabled: true,
analyticsBaseUrl: 'https://ingest.example.com',
);

$this->assertSame('https://ingest.example.com', $this->relayBaseUrlOf($stc));
}

public function test_set_analytics_base_url_overrides_default_host(): void
{
SupertabConnect::setAnalyticsBaseUrl('https://static.example.com');

$stc = new SupertabConnect(apiKey: 'k', analyticsEnabled: true);

$this->assertSame('https://static.example.com', $this->relayBaseUrlOf($stc));
}

public function test_constructor_analytics_base_url_beats_static_setter(): void
{
SupertabConnect::setAnalyticsBaseUrl('https://static.example.com');

$stc = new SupertabConnect(
apiKey: 'k',
analyticsEnabled: true,
analyticsBaseUrl: 'https://perinstance.example.com',
);

$this->assertSame('https://perinstance.example.com', $this->relayBaseUrlOf($stc));
}

public function test_analytics_host_is_independent_of_set_base_url(): void
{
SupertabConnect::setBaseUrl('https://api.example.com');

$stc = new SupertabConnect(apiKey: 'k', analyticsEnabled: true);

$this->assertSame(self::DEFAULT_INGEST, $this->relayBaseUrlOf($stc));
}

public function test_get_analytics_base_url_reflects_setter(): void
{
SupertabConnect::setAnalyticsBaseUrl('https://x.example.com');

$this->assertSame('https://x.example.com', SupertabConnect::getAnalyticsBaseUrl());
}

public function test_set_analytics_base_url_trims_trailing_slash(): void
{
SupertabConnect::setAnalyticsBaseUrl('https://x.example.com/');

$this->assertSame('https://x.example.com', SupertabConnect::getAnalyticsBaseUrl());
}
}
Loading