diff --git a/README.md b/README.md index 851ac41..2cc5a9c 100644 --- a/README.md +++ b/README.md @@ -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( @@ -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). @@ -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)); }); ``` @@ -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` @@ -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. diff --git a/src/SupertabConnect.php b/src/SupertabConnect.php index 2fd1cc3..3fc38b2 100644 --- a/src/SupertabConnect.php +++ b/src/SupertabConnect.php @@ -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; @@ -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'); @@ -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; } @@ -134,6 +148,7 @@ private function buildAnalyticsTransport( ?AnalyticsTransportInterface $injected, bool $analyticsEnabled, ?HttpClientInterface $httpClient, + string $analyticsBaseUrl, ): AnalyticsTransportInterface { if ($injected !== null) { return $injected; @@ -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, ), @@ -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. diff --git a/tests/SupertabConnectAnalyticsBaseUrlTest.php b/tests/SupertabConnectAnalyticsBaseUrlTest.php new file mode 100644 index 0000000..6d7930a --- /dev/null +++ b/tests/SupertabConnectAnalyticsBaseUrlTest.php @@ -0,0 +1,122 @@ +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)); + } + + 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()); + } +}