diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d584488 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +* text=auto eol=lf + +# Exclude dev-only files from Composer dist archives +/.github export-ignore +/tests export-ignore +phpunit.xml export-ignore +phpstan.neon export-ignore +pint.json export-ignore +.gitattributes export-ignore +.gitignore export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4c97dfc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,73 @@ +name: CI + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +jobs: + test: + name: PHP ${{ matrix.php }} — Tests + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + php: ["8.2", "8.3", "8.4"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: curl, json, mbstring, zlib + coverage: none + + - name: Install dependencies + run: composer install --prefer-dist --no-interaction --no-progress + + - name: Run tests + run: composer test + + analyse: + name: PHPStan + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.2" + extensions: curl, json, mbstring, zlib + coverage: none + + - name: Install dependencies + run: composer install --prefer-dist --no-interaction --no-progress + + - name: Run static analysis + run: composer analyse + + format: + name: Code Style + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.2" + extensions: curl, json, mbstring, zlib + coverage: none + + - name: Install dependencies + run: composer install --prefer-dist --no-interaction --no-progress + + - name: Check formatting + run: composer format -- --test diff --git a/.gitignore b/.gitignore index 85f7649..defb17a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,30 @@ -.DS_Store -/vendor +# Dependencies +/vendor/ + +# Composer composer.lock -/.idea -**.cache \ No newline at end of file + +# PHPUnit +/.phpunit.cache/ +/.phpunit.result.cache +/coverage/ + +# IDE +.idea/ +.vscode/ +*.sublime-project +*.sublime-workspace + +# OS +.DS_Store +Thumbs.db + +# Environment +.env +.env.local +.env.*.local + +# Claude Code +claude.md +CLAUDE.md +.claude/ diff --git a/LICENCE.md b/LICENSE similarity index 85% rename from LICENCE.md rename to LICENSE index e875013..c04be24 100644 --- a/LICENCE.md +++ b/LICENSE @@ -1,6 +1,6 @@ -The MIT License (MIT) +MIT License -Copyright (c) Treblle Limited. +Copyright (c) 2026 Treblle Inc Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -9,13 +9,13 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 386d18f..9d9e927 100644 --- a/README.md +++ b/README.md @@ -1,597 +1,526 @@ -## Treblle .PHP SDK - -[![Latest Version](https://img.shields.io/packagist/v/treblle/treblle-php)](https://packagist.org/packages/treblle/treblle-php) -![Packagist Downloads](https://img.shields.io/packagist/dt/treblle/treblle-php) +# Treblle - Runtime Intelligence Platform [Website](http://treblle.com/) • [Documentation](https://docs.treblle.com/) • [Pricing](https://treblle.com/pricing) -Treblle is an API intelligence platfom that helps developers, teams and organizations understand their APIs from a single integration point. +Discover, Govern, and Secure APIs, Agents, and AI Across Any Cloud, Gateway or Technology. +## Treblle PHP SDK -## Requirements +[![Latest Version](https://img.shields.io/packagist/v/treblle/treblle-php)](https://packagist.org/packages/treblle/treblle-php) +[![Total Downloads](https://img.shields.io/packagist/dt/treblle/treblle-php)](https://packagist.org/packages/treblle/treblle-php) + +--- -- PHP 8.2 or higher -- `ext-mbstring` extension (required) -- `ext-pcntl` extension (optional, for background processing) -- Composer +## Requirements +| Dependency | Version | +|-------------|---------| +| PHP | ^8.2 | +| ext-curl | any | +| ext-json | any | +| ext-mbstring | any | +| ext-zlib | any | +--- ## Installation -```sh +```bash composer require treblle/treblle-php ``` -After retrieving your API key and SDK token from the Treblle dashboard, initialize Treblle in your API code: +--- -```php - false, + 'enabled' => true, + 'masked_keywords' => [ + 'password', 'pwd', 'secret', 'password_confirmation', + 'passwordConfirmation', 'cc', 'card_number', 'cardNumber', + 'ccv', 'ssn', 'credit_score', 'creditScore', + ], + 'excluded_paths' => [], + 'custom_ingress' => null, ], - config: [ - 'client' => new Client(), // Custom HTTP client - 'url' => 'https://custom.endpoint.com', // Custom Treblle endpoint - 'fork_process' => extension_loaded('pcntl'), // Enable background processing - 'register_handlers' => true, // Auto-register error handlers (default: true) - - // Custom data providers - 'server_provider' => null, - 'language_provider' => null, - 'request_provider' => null, - 'response_provider' => null, - 'error_provider' => null, - ] ); ``` -## Features +Or build a config object directly for more control: -### 1. Automatic Data Collection +```php +use Treblle\Php\Config\TreblleConfig; +use Treblle\Php\Treblle; + +$config = new TreblleConfig( + sdkToken: $_ENV['TREBLLE_SDK_TOKEN'], + apiKey: $_ENV['TREBLLE_API_KEY'], + debug: false, + maskedKeywords: TreblleConfig::DEFAULT_MASKED_KEYWORDS, + excludedPaths: [], + customIngress: null, + enabled: true, +); + +Treblle::start($config); +``` -The SDK automatically captures and sends: +### `sdkToken` -- **Server Information**: OS, protocol, timezone, software -- **Request Data**: URL, method, headers, body, query parameters -- **Response Data**: Status code, headers, body, load time -- **Language Info**: PHP version and environment -- **Error Tracking**: Exceptions and PHP errors +Your Treblle SDK Token obtained from the Treblle Dashboard. -### 2. Sensitive Data Masking +### `apiKey` -Sensitive data is automatically masked before sending to Treblle. Default masked fields include: +Your Treblle API Key obtained from the Treblle Dashboard. -- `password`, `pwd`, `secret`, `password_confirmation` -- `cc`, `card_number`, `ccv` -- `ssn`, `credit_score` +### `enabled` -**Add custom masked fields:** +Controls whether Treblle is active. Defaults to `true`. Set to `false` to disable the SDK in specific environments without removing your credentials. ```php -$treblle = TreblleFactory::create( - apiKey: $_ENV['TREBLLE_API_KEY'], +use Treblle\Php\Treblle; + +Treblle::create( sdkToken: $_ENV['TREBLLE_SDK_TOKEN'], - maskedFields: [ - 'api_secret', - 'internal_token', - 'private_key', - ] + apiKey: $_ENV['TREBLLE_API_KEY'], + options: [ + 'enabled' => $_ENV['APP_ENV'] === 'production', + ], ); ``` -**Automatic masking:** -The SDK automatically masks sensitive data regardless of configuration: -- **Authorization headers**: Bearer, Basic, Digest tokens -- **API key headers**: `x-api-key`, `api-key` -- **Base64 images**: Replaced with `[image]` placeholder -- **All masked values**: Replaced with `*****` +### `debug` -Custom masked fields are merged with defaults, so you don't need to redefine the default fields. +When `true`, the SDK writes diagnostic messages to the PHP error log prefixed with `[TREBLLE]`. All messages are suppressed by default. -### 3. Header Filtering - -Exclude specific headers from being sent to Treblle using pattern matching: +Useful for: +- Verifying your credentials are correct +- Seeing which requests are skipped and why +- Diagnosing ingress connectivity issues ```php -$treblle = TreblleFactory::create( - apiKey: $_ENV['TREBLLE_API_KEY'], - sdkToken: $_ENV['TREBLLE_SDK_TOKEN'], - excludedHeaders: [ - 'X-Internal-*', // Wildcard: excludes all headers starting with X-Internal- - 'X-Debug-Token', // Exact: excludes only this specific header - '/^X-(Debug|Test)-/i', // Regex: excludes X-Debug-* and X-Test-* headers - ] -); +'debug' => true, ``` -**Matching strategies:** -- **Exact match**: `"X-Custom-Header"` matches only that specific header (case-insensitive) -- **Wildcard**: `"X-Internal-*"` matches all headers starting with prefix -- **Regex**: Patterns wrapped in `/` are treated as regular expressions +Example debug output: -All header matching is case-insensitive by default. +``` +[TREBLLE] Async: using fastcgi_finish_request +[TREBLLE] Request excluded by path: /health +[TREBLLE] Received 4xx from Treblle ingress: 422 +[TREBLLE] curl error: Could not resolve host +``` -### 4. Error & Exception Tracking +Debug output goes to wherever PHP's `error_log` is configured - typically your web server error log or a file defined in `php.ini`. -The SDK automatically captures all PHP errors and exceptions: +### `masked_keywords` -- **PHP Errors**: `E_ERROR`, `E_WARNING`, `E_NOTICE`, `E_DEPRECATED`, etc. -- **Exceptions**: Uncaught exceptions with full stack traces -- **Shutdown Errors**: Fatal errors caught during PHP shutdown +Field names to mask in request bodies, response bodies, request headers, and response headers. Masking replaces every character of the value with `*`, preserving the original length, and is applied recursively to nested objects and arrays. -All error types are automatically translated from integers to readable strings (e.g., `E_WARNING`). +The SDK ships with a sensible default list. You control the list entirely - extend it, replace it, or set it to `[]` to disable masking. ```php -// Errors are automatically tracked when handlers are registered (default behavior) -$treblle = TreblleFactory::create( - apiKey: $_ENV['TREBLLE_API_KEY'], - sdkToken: $_ENV['TREBLLE_SDK_TOKEN'], - config: ['register_handlers' => true] // Default: true -); +use Treblle\Php\Config\TreblleConfig; + +// Extend the defaults +'masked_keywords' => array_merge( + TreblleConfig::DEFAULT_MASKED_KEYWORDS, + ['access_token', 'refresh_token', 'api_secret'], +), ``` -**Disable automatic error handling:** ```php -$treblle = TreblleFactory::create( - apiKey: $_ENV['TREBLLE_API_KEY'], - sdkToken: $_ENV['TREBLLE_SDK_TOKEN'], - config: ['register_handlers' => false] -); +// Disable masking entirely +'masked_keywords' => [], +``` + +The `Authorization` header is masked with scheme-preserving formatting (e.g. `Bearer ****`) when it appears in the keyword list. + +### `excluded_paths` + +Paths that should not be tracked by Treblle. Supports exact matches, wildcards, and regular expressions. -// Manually register handlers if needed -set_error_handler([$treblle, 'onError']); -set_exception_handler([$treblle, 'onException']); -register_shutdown_function([$treblle, 'onShutdown']); +```php +'excluded_paths' => [ + '/health', // exact match + '/uptime', // exact match + '/status', // exact match + 'admin/*', // wildcard: matches /admin/users, /admin/settings, etc. + '/^\/debug\//i', // regex: matches any path starting with /debug/ +], ``` -### 5. Debug Mode +**Exact match** - the full path must match character for character (case-insensitive): -Enable debug mode during development to see detailed error messages: +```php +'/health' // matches /health only +``` + +**Wildcard** - use `*` (any sequence) or `?` (any single character): ```php -$treblle = TreblleFactory::create( - apiKey: $_ENV['TREBLLE_API_KEY'], - sdkToken: $_ENV['TREBLLE_SDK_TOKEN'], - debug: true // Throws exceptions instead of silently failing -); +'admin/*' // matches admin/users, admin/settings/edit, etc. +'api/v?/*' // matches api/v1/anything, api/v2/anything, etc. ``` -**Debug mode behavior:** -- **OFF (default)**: SDK errors are silently caught and ignored, ensuring your application continues running -- **ON**: SDK throws exceptions for easier troubleshooting during development +**Regex** - any string that starts and ends with `/` is treated as a regex: -**Recommended usage:** ```php -$treblle = TreblleFactory::create( - apiKey: $_ENV['TREBLLE_API_KEY'], - sdkToken: $_ENV['TREBLLE_SDK_TOKEN'], - debug: $_ENV['APP_ENV'] === 'development' -); +'/^\/api\/v\d+\/internal/' // matches /api/v1/internal, /api/v2/internal, etc. ``` -### 6. Background Processing +### `custom_ingress` -Improve performance by sending data to Treblle in a background process: +Override the default ingress endpoint. Useful for EU-hosted or self-hosted Treblle deployments: ```php -$treblle = TreblleFactory::create( - apiKey: $_ENV['TREBLLE_API_KEY'], - sdkToken: $_ENV['TREBLLE_SDK_TOKEN'], - config: [ 'fork_process' => extension_loaded('pcntl') ] -); +'custom_ingress' => 'https://ingress-eu.treblle.com', ``` -**How it works:** -- When enabled, the SDK uses `pcntl_fork()` to create a child process -- The child process sends data to Treblle while the parent continues/exits immediately -- Falls back to blocking transmission if forking fails -- Requires the `pcntl` extension (not available on Windows) +--- -**Performance impact:** -- **With fork**: ~0ms added to response time (non-blocking) -- **Without fork**: ~3-6ms added to response time (3-second timeout) +## Endpoint Detection -**Note**: The `pcntl` extension is typically available on Linux/macOS but not on Windows. +In plain PHP we are unable to effectively determine the endpoint path for a request. Example for: `articles/12345` the endpoint path would be: `articles/{id}`. This allows us to build a much more accurate representation of how your API works. -### 7. Custom Data Providers +However there are two ways to supply it to us: -Override default data providers for custom implementations: +### Option 1 - `Treblle::setRoutePath()` -```php -use Treblle\Php\Contract\RequestDataProvider; -use Treblle\Php\DataTransferObject\Request; -use Treblle\Php\Helpers\SensitiveDataMasker; +Call this after your router has resolved the route, before the request completes: -class CustomRequestProvider implements RequestDataProvider -{ - public function __construct( - private SensitiveDataMasker $masker, - private array $excludedHeaders = [] - ) {} - - public function getRequest(): Request - { - // Your custom implementation - return new Request( - timestamp: gmdate('Y-m-d H:i:s'), - url: 'https://api.example.com/endpoint', - ip: '192.168.1.1', - user_agent: 'Custom Client', - method: 'POST', - headers: ['Content-Type' => 'application/json'], - body: $this->masker->mask(['data' => 'value']) - ); - } -} +```php +use Treblle\Php\Treblle; -$masker = new SensitiveDataMasker(['password', 'secret']); -$treblle = TreblleFactory::create( - apiKey: $_ENV['TREBLLE_API_KEY'], - sdkToken: $_ENV['TREBLLE_SDK_TOKEN'], - config: [ - 'request_provider' => new CustomRequestProvider($masker, ['X-Internal-*']), - ] -); +Treblle::setRoutePath('articles/{id}'); ``` -**Available provider interfaces:** -- `ServerDataProvider` - Server/OS information -- `LanguageDataProvider` - PHP version and runtime -- `RequestDataProvider` - HTTP request data -- `ResponseDataProvider` - HTTP response data -- `ErrorDataProvider` - Error and exception storage +Works with any routing library. Example with [nikic/fast-route](https://github.com/nikic/FastRoute): -**Default implementations:** -- `SuperGlobalsServerDataProvider` - Uses `$_SERVER` superglobal -- `PhpLanguageDataProvider` - Uses `PHP_VERSION` constant -- `SuperGlobalsRequestDataProvider` - Uses `$_SERVER`, `$_REQUEST`, `getallheaders()` -- `OutputBufferingResponseDataProvider` - Uses output buffering (`ob_*` functions) -- `InMemoryErrorDataProvider` - In-memory array storage +```php +$dispatcher = FastRoute\simpleDispatcher(function (FastRoute\RouteCollector $r) { + $r->addRoute('GET', '/v1/articles/{id:\d+}', 'ArticleHandler'); +}); -## Important Considerations +[$status, $handler, $vars] = $dispatcher->dispatch($method, $path); -### Output Buffering +if ($status === FastRoute\Dispatcher::FOUND) { + Treblle::setRoutePath('/v1/articles/{id}'); +} +``` + +### Option 2 - `$_SERVER['TREBLLE_ROUTE_PATH']` -The SDK requires output buffering to capture response data: +Set the server variable anywhere before the shutdown handler runs. Useful when Treblle is initialised in a bootstrap file and the route path is resolved in a different layer: ```php -// REQUIRED: Start output buffering before creating Treblle instance -ob_start(); +$_SERVER['TREBLLE_ROUTE_PATH'] = 'articles/{id}'; +``` -$treblle = TreblleFactory::create( - apiKey: $_ENV['TREBLLE_API_KEY'], - sdkToken: $_ENV['TREBLLE_SDK_TOKEN'] -); +You can also set it from `.htaccess` for simple setups where a rewrite rule maps to a known pattern: + +```apache +RewriteRule ^v1/articles/([0-9]+)$ index.php [L,E=TREBLLE_ROUTE_PATH:articles/{id}] ``` -**Response limitations:** -- Responses >= 2MB: Logged as error, empty body sent to Treblle -- Invalid JSON: Logged as error, empty body sent to Treblle -- The SDK uses `ob_get_flush()` which both retrieves and flushes the buffer +`setRoutePath()` always takes priority over `$_SERVER['TREBLLE_ROUTE_PATH']`. -### Response Data Format +--- -The SDK expects JSON responses. If your API returns JSON: -- Response body will be decoded and masked automatically -- Non-JSON responses will result in an empty body being sent +## Enrich Requests With Metadata -### IP Detection +Attach custom key/value pairs to any request that will show up on the Treblle dashboard and you can filter/search for requests with specific metadata. -The SDK automatically detects client IP addresses with proxy support: -1. Checks `HTTP_CLIENT_IP` first -2. Falls back to `HTTP_X_FORWARDED_FOR` (for proxied requests) -3. Finally uses `REMOTE_ADDR` (direct connection) -4. Defaults to `'bogon'` if no IP found +Note: `user-id` is a reserved keyword for helping us build entire customer dashboards and track customer usage across your API. +```php +use Treblle\Php\Treblle; -## Usage Examples +Treblle::metadata([ + 'user-id' => 'john@acmecorp.com', + 'tenant' => 'acme-corp', + 'plan' => 'enterprise', +]); +``` -### Basic PHP Application +Call `Treblle::metadata()` anywhere during the request lifecycle - in a middleware, after authentication, inside a controller. Multiple calls are merged together: ```php - $user->id, 'role' => $user->role]); -declare(strict_types=1); +// Later, in a controller +Treblle::metadata(['feature_flag' => 'new-checkout']); +``` -use Treblle\Php\Factory\TreblleFactory; +The final payload will contain both sets of keys: -require_once __DIR__ . '/vendor/autoload.php'; +```json +{ + "data": { + "metadata": { + "user-id": 42, + "role": "admin", + "feature_flag": "new-checkout" + } + } +} +``` -// IMPORTANT: Start output buffering before Treblle initialization -ob_start(); +Metadata values can be any JSON-serializable type: strings, integers, booleans, or nested arrays. -// Initialize Treblle -$treblle = TreblleFactory::create( - apiKey: $_ENV['TREBLLE_API_KEY'], - sdkToken: $_ENV['TREBLLE_SDK_TOKEN'] -); +In persistent runtimes (Swoole, RoadRunner, FrankenPHP), call `Treblle::reset()` at the start of each request cycle to clear metadata from the previous request alongside all other request state. -// Your API logic -header('Content-Type: application/json'); -echo json_encode(['message' => 'Hello, World!']); +--- -// Output buffering will be captured automatically on shutdown -``` +## PSR-15 Middleware -### Production Configuration +If your application uses a PSR-15 compatible framework (Slim, Mezzio, Laravel via `league/route`, etc.) you can use `TreblleMiddleware` instead of `Treblle::create()`. The middleware integrates directly into the PSR-15 stack, reads request and response data from the PSR-7 objects (no output buffering), and ships the payload asynchronously via a shutdown function after the response has been delivered. ```php -add($middleware); -require_once __DIR__ . '/vendor/autoload.php'; +// Mezzio / Laminas +$app->pipe($middleware); +``` -ob_start(); +Register it as the **outermost** middleware so it wraps the full request/response cycle and captures all headers and the complete response body. -$treblle = TreblleFactory::create( - apiKey: $_ENV['TREBLLE_API_KEY'], +All options from `Treblle::create()` are supported as the third argument: + +```php +$middleware = TreblleMiddleware::create( sdkToken: $_ENV['TREBLLE_SDK_TOKEN'], - debug: false, // Silent failures in production - maskedFields: [ - 'internal_token', - 'private_data', - 'admin_password', - 'api_secret', + apiKey: $_ENV['TREBLLE_API_KEY'], + options: [ + 'enabled' => $_ENV['APP_ENV'] === 'production', + 'masked_keywords' => array_merge(TreblleConfig::DEFAULT_MASKED_KEYWORDS, ['access_token']), + 'excluded_paths' => ['/health', '/metrics'], ], - excludedHeaders: [ - 'X-Internal-*', // Exclude internal headers - 'X-Debug-*', // Exclude debug headers - ], - config: [ - 'fork_process' => extension_loaded('pcntl'), // Non-blocking on Linux/macOS - ] ); - -// Your application code -header('Content-Type: application/json'); -$data = ['users' => [...], 'meta' => [...]]; -echo json_encode($data); ``` -### Development Configuration +Or construct with a config object directly: ```php - false, // Blocking mode for easier debugging - ] -); - -// Your application code +```php +$request = $request->withAttribute('_treblle_route_path', 'articles/{id}'); ``` -### Conditional Configuration +`Treblle::setRoutePath()` also works from inside the handler if you prefer that approach. -```php -auth->authenticate($request); + Treblle::metadata(['user_id' => $user->id, 'plan' => $user->plan]); -$treblle = TreblleFactory::create( - apiKey: $_ENV['TREBLLE_API_KEY'], - sdkToken: $_ENV['TREBLLE_SDK_TOKEN'], - debug: !$isProduction, // Debug only in non-production - maskedFields: $isProduction - ? ['password', 'secret', 'internal_token', 'private_key'] - : ['password', 'secret'], - excludedHeaders: $isProduction - ? ['X-Internal-*', 'X-Debug-*'] - : [], - config: [ - 'fork_process' => $isProduction && extension_loaded('pcntl'), - ] -); + // ... handle request ... +} ``` -## Troubleshooting +--- -### Output Buffering Not Enabled +## Async Mode -**Error:** `RuntimeException: Output buffering must be enabled to collect responses. Have you called 'ob_start()'?` +The SDK sends data to Treblle after your response has been delivered, using the first available strategy: -**Solution:** Call `ob_start()` before creating the Treblle instance: -```php -ob_start(); // Add this line -$treblle = TreblleFactory::create(...); -``` +1. **`fastcgi_finish_request()`** (PHP-FPM) - flushes the HTTP response to the client and continues running in the background. The Treblle call is completely invisible to your end users. This is the default in most production PHP deployments. -### No Data Appearing in Dashboard +2. **`pcntl_fork()`** (Linux/Unix CLI and FPM where `pcntl` is available) - forks a child process to send the data. The parent process returns immediately. -1. **Check API credentials:** - ```php - var_dump($_ENV['TREBLLE_API_KEY'], $_ENV['TREBLLE_SDK_TOKEN']); - ``` +3. **Sync fallback** - if neither of the above is available, the data is sent synchronously after the response is flushed. This adds network latency to your shutdown phase but does not affect the response your users receive. -2. **Enable debug mode to see errors:** - ```php - $treblle = TreblleFactory::create( - apiKey: $_ENV['TREBLLE_API_KEY'], - sdkToken: $_ENV['TREBLLE_SDK_TOKEN'], - debug: true // Will throw exceptions if something fails - ); - ``` +No configuration is required. The SDK detects the environment at runtime and picks the best available strategy. -3. **Check if handlers are registered:** - ```php - $treblle = TreblleFactory::create( - apiKey: $_ENV['TREBLLE_API_KEY'], - sdkToken: $_ENV['TREBLLE_SDK_TOKEN'], - config: ['register_handlers' => true] // Should be true (default) - ); - ``` +--- -### Response Body Empty +## Migrating from v5 to v6 -**Possible causes:** -- Response size >= 2MB (check your response size) -- Invalid JSON in response (check JSON encoding) -- Output buffering not capturing response (ensure `ob_start()` is called early) +### 1. Update the package -**Solution:** Check error logs when debug mode is enabled. +```bash +composer require treblle/treblle-php:^6.0 +``` -### Fork Process Not Working +### 2. Remove the Guzzle dependency -**Issue:** Background processing not working on your system. +v6 uses native PHP curl - Guzzle is no longer required. If your project only pulled it in for Treblle, you can remove it: -**Check if pcntl is available:** -```php -if (extension_loaded('pcntl')) { - echo "PCNTL available"; -} else { - echo "PCNTL not available - will use blocking mode"; -} +```bash +composer remove guzzlehttp/guzzle ``` -**Note:** The `pcntl` extension is not available on Windows. The SDK will automatically fall back to blocking mode. +If other packages in your project depend on Guzzle, leave it - removing it won't break anything, it's simply no longer used by this SDK. + +### 3. Replace `TreblleFactory::create()` with `Treblle::create()` -### Custom Headers Not Being Excluded +The factory class is gone. Initialisation now goes through `Treblle::create()` directly. -**Issue:** Headers still appearing in Treblle despite being in `excludedHeaders`. +**Before (v5):** -**Solution:** Check pattern syntax: ```php -excludedHeaders: [ - 'X-Debug-Token', // Exact match (case-insensitive) - 'X-Internal-*', // Wildcard pattern - '/^Authorization$/i' // Regex pattern (wrapped in /) -] +use Treblle\Php\Factory\TreblleFactory; + +TreblleFactory::create( + apiKey: 'your-api-key', + sdkToken: 'your-sdk-token', +); ``` -## Migration Guide (v4.x to v5.x) +**After (v6):** -Version 5.0 introduces breaking changes to parameter naming for better clarity: +```php +use Treblle\Php\Treblle; + +Treblle::create( + sdkToken: 'your-sdk-token', + apiKey: 'your-api-key', +); +``` -### What Changed +Note that the parameter order is reversed - `sdkToken` is now first. -- **Parameter names renamed**: - - `projectId` → `apiKey` (env: `TREBLLE_PROJECT_ID` → `TREBLLE_API_KEY`) - - `apiKey` → `sdkToken` (env: `TREBLLE_API_KEY` → `TREBLLE_SDK_TOKEN`) +### 4. Update renamed and moved options -### Migration Steps +| v5 | v6 | Notes | +|---|---|---| +| `$maskedFields` (3rd positional arg) | `options['masked_keywords']` | Renamed; now controls the full list, not just additions | +| `$excludedHeaders` (4th positional arg) | _(removed)_ | See below | +| `$config['url']` | `options['custom_ingress']` | Renamed | +| `$config['fork_process']` | _(removed)_ | Async is now automatic | +| `$config['register_handlers']` | _(removed)_ | Handlers are always registered | +| _(new)_ | `options['enabled']` | Global on/off switch | + +**Full before/after example:** -**Before (v4.x):** ```php -$treblle = TreblleFactory::create( - apiKey: 'your-api-key', - projectId: 'your-project-id' +// Before (v5) +use Treblle\Php\Factory\TreblleFactory; + +TreblleFactory::create( + apiKey: 'your-api-key', + sdkToken: 'your-sdk-token', + debug: false, + maskedFields: ['access_token', 'refresh_token'], + excludedHeaders: ['X-Internal-Header'], + config: [ + 'url' => 'https://ingress-eu.treblle.com', + 'fork_process' => true, + ], ); ``` -**After (v5.x):** ```php -$treblle = TreblleFactory::create( - apiKey: 'your-project-id', // This is now your API key - sdkToken: 'your-api-key' // This is now your SDK token +// After (v6) +use Treblle\Php\Treblle; + +Treblle::create( + sdkToken: 'your-sdk-token', + apiKey: 'your-api-key', + options: [ + 'debug' => false, + 'masked_keywords' => ['password', 'pwd', 'secret', 'access_token', 'refresh_token'], + 'custom_ingress' => 'https://ingress-eu.treblle.com', + ], ); ``` -**Environment Variables:** -```bash -# Before (v4.x) -export TREBLLE_API_KEY="your-api-key" -export TREBLLE_PROJECT_ID="your-project-id" +### 5. Review `maskedFields` vs `masked_keywords` -# After (v5.x) -export TREBLLE_API_KEY="your-project-id" -export TREBLLE_SDK_TOKEN="your-api-key" +In v5, `$maskedFields` was **additive** - the SDK merged your list with its own defaults. + +In v6, `masked_keywords` is the **full list**. If you pass a value, it replaces the defaults entirely. To extend the defaults, merge them explicitly: + +```php +use Treblle\Php\Config\TreblleConfig; +use Treblle\Php\Treblle; + +Treblle::create( + sdkToken: 'your-sdk-token', + apiKey: 'your-api-key', + options: [ + 'masked_keywords' => array_merge( + TreblleConfig::DEFAULT_MASKED_KEYWORDS, + ['access_token', 'refresh_token'], + ), + ], +); ``` -## Getting Help +### 6. `excludedHeaders` is removed + +v5's `$excludedHeaders` excluded header **names** from being sent to Treblle at all. v6 does not have this option. Instead: + +- To hide the **value** of a sensitive header, add its name to `masked_keywords` - the value will be replaced with `*` characters. -If you continue to experience issues: +### 7. Async is now automatic -1. Enable `debug: true` and check console output -2. Verify your SDK token and API key are correct in Treblle dashboard -3. Test with a simple endpoint first -4. Check [Treblle documentation](https://docs.treblle.com) for the latest updates -5. Contact support at or email support@treblle.com +In v5, background sending required explicitly setting `config['fork_process' => true]` and having the `pcntl` extension available. -## Support +In v6, the SDK automatically uses the best available async strategy at runtime - `fastcgi_finish_request()` in PHP-FPM environments, `pcntl_fork()` on Unix where available, and a sync fallback otherwise. No configuration needed. -If you have problems of any kind feel free to reach out via or email support@treblle.com and we'll do our best to help you out. +--- ## License -Copyright 2025, Treblle Inc. Licensed under the MIT license: -http://www.opensource.org/licenses/mit-license.php +The MIT License (MIT). See [LICENSE](LICENSE) for details. diff --git a/claude.md b/claude.md deleted file mode 100644 index 42c101a..0000000 --- a/claude.md +++ /dev/null @@ -1,551 +0,0 @@ -# Treblle PHP SDK - -## Project Overview - -This is the official Treblle PHP SDK - a lightweight library that sends API request/response data to the Treblle API Intelligence Platform for monitoring, analytics, and auto-generated documentation. - -**Version**: 5.0 -**PHP Requirements**: ^8.2 -**License**: MIT - -## Architecture - -### Core Components - -1. **Treblle** (`src/Treblle.php`) - Main class that: - - Registers error/exception handlers - - Captures PHP errors and exceptions - - Builds and sends payload to Treblle on shutdown - - Supports optional background processing via `pcntl_fork` - -2. **TreblleFactory** (`src/Factory/TreblleFactory.php`) - Factory for creating Treblle instances with: - - Default configuration - - Field masking setup - - Automatic handler registration - -3. **Data Providers** (Provider pattern): - - `ServerDataProvider` - Server/OS information (`SuperGlobalsServerDataProvider`) - - `LanguageDataProvider` - PHP version info (`PhpLanguageDataProvider`) - - `RequestDataProvider` - HTTP request data (`SuperGlobalsRequestDataProvider`) - - `ResponseDataProvider` - HTTP response data (`OutputBufferingResponseDataProvider`) - - `ErrorDataProvider` - PHP errors/exceptions (`InMemoryErrorDataProvider`) - -4. **DTOs** (`src/DataTransferObject/`): - - `Data` - Top-level payload wrapper - - `Server` - Server information - - `Language` - Language/runtime info - - `Request` - HTTP request details - - `Response` - HTTP response details - - `Error` - Error/exception details - - `Os` - Operating system info - -5. **Sensitive Data Masking** (`src/Helpers/SensitiveDataMasker.php`): - - Masks sensitive fields in request/response data - - Default masked fields: password, secret, card_number, ssn, etc. - - Custom masked fields support - - Handles authorization headers, API keys, and base64-encoded images - -6. **Header Filtering** (`src/Helpers/HeaderFilter.php`): - - Filters HTTP headers based on exclusion patterns - - Supports exact matching, wildcard patterns, and regex - - Custom excluded headers support - -7. **Error Type Translation** (`src/Helpers/ErrorTypeTranslator.php`): - - Translates PHP error type integers to string constant names - - Handles all PHP error types (E_ERROR, E_WARNING, etc.) - -### Key Features - -- **Automatic data collection**: Captures server, request, response, language, and error data -- **Sensitive data masking**: Sensitive fields and data removed before sending to Treblle -- **Header filtering**: Exclude specific headers based on patterns before sending to Treblle -- **Error tracking**: PHP errors, exceptions, and shutdown errors -- **Background processing**: Optional `pcntl_fork` for non-blocking data transmission -- **Debug mode**: Throws exceptions instead of silent failures -- **Custom providers**: Inject custom data providers for special environments - -### Data Flow - -``` -1. User creates Treblle instance via TreblleFactory -2. Handlers registered: error_handler, exception_handler, shutdown_function -3. During request processing: - - Errors/exceptions captured by handlers - - Response captured via output buffering -4. On shutdown: - - Build payload from all providers - - Mask sensitive fields - - Filter excluded headers - - Send to Treblle API (optionally in forked process) -``` - -### Payload Structure - -The complete JSON payload sent to Treblle has this structure: - -```json -{ - "api_key": "your-api-key", - "sdk_token": "your-sdk-token", - "sdk": "php", - "version": 5.0, - "data": { - "server": { - "protocol": "HTTP/1.1", - "software": "Apache/2.4.41", - "signature": "...", - "timezone": "UTC", - "os": { - "name": "Linux", - "release": "5.10.0", - "architecture": "x86_64" - } - }, - "language": { - "name": "php", - "version": "8.2.15" - }, - "request": { - "timestamp": "2025-01-15 10:30:45", - "url": "https://api.example.com/users?page=1", - "ip": "192.168.1.100", - "user_agent": "Mozilla/5.0...", - "method": "POST", - "headers": { - "Content-Type": "application/json", - "Accept": "application/json" - }, - "body": { - "email": "user@example.com", - "password": "*****" - } - }, - "response": { - "code": 200, - "size": 1024, - "load_time": 123.45, - "headers": { - "Content-Type": "application/json" - }, - "body": { - "id": 123, - "email": "user@example.com" - } - }, - "errors": [ - { - "message": "Undefined variable: foo", - "file": "/var/www/app.php", - "line": 42, - "source": "onError", - "type": "E_WARNING" - } - ] - } -} -``` - -All DTOs implement `JsonSerializable` for automatic JSON encoding. - -## Configuration - -### Basic Usage - -```php -use Treblle\Php\Factory\TreblleFactory; - -$treblle = TreblleFactory::create( - apiKey: $_ENV['TREBLLE_API_KEY'], // Your project API key - sdkToken: $_ENV['TREBLLE_SDK_TOKEN'], // Your SDK token -); -``` - -### Advanced Configuration - -```php -$treblle = TreblleFactory::create( - apiKey: 'your-api-key', - sdkToken: 'your-sdk-token', - debug: false, // Enable for dev (throws exceptions) - maskedFields: ['custom_secret'], // Additional fields to mask - excludedHeaders: [ // Headers to exclude from Treblle - 'X-Internal-*', // Wildcard patterns supported - 'X-Debug-Token', // Exact matches - '/^Authorization$/i', // Regex patterns - ], - config: [ - 'client' => new Client(), // Custom Guzzle client - 'url' => 'https://custom.endpoint', // Custom Treblle endpoint - 'fork_process' => false, // Enable background processing - 'register_handlers' => true, // Auto-register handlers - - // Custom providers - 'server_provider' => null, - 'language_provider' => null, - 'request_provider' => null, - 'response_provider' => null, - 'error_provider' => null, - ] -); -``` - -### Environment Variables - -- `TREBLLE_API_KEY` - Your Treblle API key -- `TREBLLE_SDK_TOKEN` - Your Treblle SDK token - -## Coding Standards - -### Style Guide - -- **PSR-12** coding standard (enforced via Laravel Pint) -- **Strict types** declaration in all files: `declare(strict_types=1);` -- **Type hints** for all parameters and return types -- **Final classes** by default (see `Treblle`, `TreblleFactory`) -- **Named arguments** in factory/constructor calls for clarity - -### File Organization - -``` -src/ -├── Contract/ # Interfaces for data providers -│ ├── ErrorDataProvider.php -│ ├── LanguageDataProvider.php -│ ├── RequestDataProvider.php -│ ├── ResponseDataProvider.php -│ └── ServerDataProvider.php -├── DataTransferObject/ # DTOs for structured data (all implement JsonSerializable) -│ ├── Data.php # Top-level payload wrapper -│ ├── Error.php # Error/exception representation -│ ├── Language.php # PHP runtime information -│ ├── Os.php # Operating system details -│ ├── Request.php # HTTP request data -│ ├── Response.php # HTTP response data -│ └── Server.php # Server/environment information -├── DataProviders/ # Concrete provider implementations -│ ├── InMemoryErrorDataProvider.php -│ ├── OutputBufferingResponseDataProvider.php -│ ├── PhpLanguageDataProvider.php -│ ├── SuperGlobalsRequestDataProvider.php -│ └── SuperGlobalsServerDataProvider.php -├── Factory/ # Factory classes -│ └── TreblleFactory.php -├── Helpers/ # Utility classes -│ ├── ErrorTypeTranslator.php # PHP error type to string conversion -│ ├── HeaderFilter.php # HTTP header filtering -│ └── SensitiveDataMasker.php # Sensitive data masking -└── Treblle.php # Core SDK class -``` - -### Running Code Quality Tools - -```bash -# Format code with Laravel Pint -composer pint -``` - -## Important Implementation Details - -### Sensitive Data Masking - -Default masked fields (case-insensitive): -- `password`, `pwd`, `secret`, `password_confirmation` -- `cc`, `card_number`, `ccv` -- `ssn`, `credit_score` - -Also automatically masks: -- Authorization headers (Bearer, Basic, Digest) -- API key headers (`x-api-key`) -- Base64 encoded images - -### Header Filtering - -Headers can be excluded before sending to Treblle using patterns: -- **Exact match**: `"X-Custom-Header"` excludes only that header -- **Wildcard**: `"X-Internal-*"` excludes all headers starting with "X-Internal-" -- **Regex**: `"/^Authorization$/i"` uses regular expression matching -- All matching is case-insensitive by default - -### Output Buffering - -The SDK uses `ob_start()` to capture response output. The `OutputBufferingResponseDataProvider` requires output buffering to be enabled and will throw a `RuntimeException` if `ob_get_level() < 1`. - -**Important limitations**: -- Responses >= 2MB: Logged as error, returns empty body -- Invalid JSON: Logged as error, returns empty body -- Uses `ob_get_flush()` to retrieve and flush buffered output -- Calculates response size and load time from `REQUEST_TIME_FLOAT` - -### Background Processing - -When `fork_process: true` and `pcntl_fork` is available: -- Forks child process to send data using `pcntl_fork()` -- Main process continues/exits immediately (non-blocking) -- Child process sends data then terminates itself via platform-specific kill command -- Windows: `taskkill /F /T /PID {pid}` -- Unix/Linux/macOS: `kill -9 {pid}` -- Falls back to blocking transmission if fork fails (returns -1) - -### Error Handling - -- **Debug mode OFF** (default): Silently catches and ignores SDK errors -- **Debug mode ON**: Throws exceptions for easier debugging - -### Multiple Treblle Endpoints - -The SDK randomly selects from 3 Treblle endpoints for load balancing: -- `https://rocknrolla.treblle.com` -- `https://punisher.treblle.com` -- `https://sicario.treblle.com` - -Override with `config['url']` if needed. - -**HTTP Request Configuration**: -- Method: POST -- Connection timeout: 3 seconds -- Request timeout: 3 seconds -- SSL verification: Disabled (`verify: false`) -- HTTP errors: Suppressed (`http_errors: false`) -- Headers: `Content-Type: application/json`, `x-api-key: {sdk_token}` -- Short timeouts ensure minimal performance impact - -## Testing - -Currently no test suite is included in this repository. When adding tests: -- Use PHPUnit -- Add tests to `tests/` directory -- Follow PSR-4 autoloading: `Treblle\Php\Tests\` - -## Migration Notes - -### v4.x to v5.x - -**Breaking Changes**: -- Parameter names renamed for clarity: - - `projectId` → `apiKey` - - `apiKey` → `sdkToken` -- Environment variables renamed: - - `TREBLLE_PROJECT_ID` → `TREBLLE_API_KEY` - - `TREBLLE_API_KEY` → `TREBLLE_SDK_TOKEN` - -## Data Provider Details - -### SuperGlobalsServerDataProvider -Collects server and OS information from `$_SERVER` superglobal: -- Protocol (HTTP/HTTPS from `$_SERVER['SERVER_PROTOCOL']`) -- Software (from `$_SERVER['SERVER_SOFTWARE']`) -- Signature (from `$_SERVER['SERVER_SIGNATURE']`) -- Timezone (from `date_default_timezone_get()`) -- OS information (name, release, architecture via `php_uname()`) - -### PhpLanguageDataProvider -Returns PHP runtime information: -- Name: Always 'php' -- Version: From `PHP_VERSION` constant - -### SuperGlobalsRequestDataProvider -Collects HTTP request data: -- Timestamp in UTC (Y-m-d H:i:s format) -- Full URL with protocol detection (HTTPS check) -- Client IP with proxy detection (`HTTP_CLIENT_IP`, `HTTP_X_FORWARDED_FOR`, `REMOTE_ADDR`) -- User-Agent from `$_SERVER['HTTP_USER_AGENT']` -- HTTP method from `$_SERVER['REQUEST_METHOD']` -- Headers from `getallheaders()` (filtered via HeaderFilter) -- Request body from `$_REQUEST` (masked via SensitiveDataMasker) - -### OutputBufferingResponseDataProvider -Collects HTTP response data: -- HTTP status code from `http_response_code()` (defaults to 200) -- Response size from `ob_get_length()` -- Load time in milliseconds (from `REQUEST_TIME_FLOAT` to current time) -- Response body from `ob_get_flush()` decoded as JSON (masked) -- Response headers from `headers_list()` (filtered) -- Requires output buffering enabled before instantiation - -### InMemoryErrorDataProvider -Stores errors in memory during request lifecycle: -- Simple array-based storage -- Errors added via `addError()` during request processing -- All errors retrieved via `getErrors()` during shutdown -- Stores Error DTOs created by error/exception handlers - -## Common Tasks - -### Adding a New Data Provider - -1. Create interface in `src/Contract/` -2. Implement provider class in `src/DataProviders/` -3. Update `TreblleFactory` with default implementation -4. Allow override via `config` array parameter -5. Add comprehensive PHPDoc blocks following project standards - -### Modifying Masked Fields - -Add to default list in `TreblleFactory::create()` or pass via `maskedFields` parameter. - -Custom fields are merged with defaults: -```php -$treblle = TreblleFactory::create( - apiKey: 'key', - sdkToken: 'token', - maskedFields: ['custom_secret', 'internal_token'] -); -``` - -### Adding Excluded Headers - -Pass header patterns via `excludedHeaders` parameter: -```php -$treblle = TreblleFactory::create( - apiKey: 'key', - sdkToken: 'token', - excludedHeaders: [ - 'X-Debug-*', // Wildcard - 'X-Internal-Token', // Exact match - '/^X-Custom-.*/i' // Regex - ] -); -``` - -### Debugging SDK Issues - -1. Enable debug mode: `debug: true` -2. Check error logs for exceptions -3. Verify API key and SDK token -4. Test with custom endpoint: `config['url']` -5. Check output buffering is enabled: `ob_get_level() >= 1` -6. Verify handler registration: `config['register_handlers'] = true` - -## Dependencies - -- **guzzlehttp/guzzle**: `^7.4.5 || ^8.0 || ^9.0` - HTTP client for sending data (supports latest Guzzle versions) -- **ext-mbstring**: Required for string operations -- **ext-pcntl**: Optional, for background processing - -### Guzzle Version Support - -The SDK is tested and compatible with: -- **Guzzle 7.x** (currently 7.10.0) - Latest stable version -- **Guzzle 8.x** - Future-proof for when released -- **Guzzle 9.x** - Future-proof for when released - -The SDK uses standard Guzzle PSR-18 client interfaces, ensuring compatibility across versions. - -## Development Dependencies - -- **laravel/pint**: `^1.15` - Code formatter (PSR-12) - -## Links - -- **Platform**: https://platform.treblle.com -- **Documentation**: https://docs.treblle.com -- **Integration Docs**: https://docs.treblle.com/en/integrations/php -- **Support**: https://treblle.com/chat (Discord) - -## Helper Classes Deep Dive - -### SensitiveDataMasker (`src/Helpers/SensitiveDataMasker.php`) - -**Purpose**: Recursively masks sensitive data in arrays before transmission to Treblle. - -**Features**: -- Case-insensitive field name matching -- Recursive processing of nested arrays -- Special handling for authorization headers (Bearer, Basic, Digest) -- Detection and masking of API key headers (`x-api-key`) -- Base64-encoded image detection and replacement with `[image]` - -**Usage**: -```php -$masker = new SensitiveDataMasker(['password', 'secret']); -$masked = $masker->mask($data); -``` - -**Masking strategy**: -- String values: Replaced with `'*****'` -- Non-string values: Replaced with empty string -- Base64 images: Replaced with `'[image]'` -- Authorization headers: Completely masked regardless of pattern - -### HeaderFilter (`src/Helpers/HeaderFilter.php`) - -**Purpose**: Filters HTTP headers based on exclusion patterns. - -**Matching strategies**: -1. **Exact matching**: `"X-Custom-Header"` matches only that specific header (case-insensitive) -2. **Wildcard patterns**: `"X-Internal-*"` matches all headers starting with "X-Internal-" -3. **Regex patterns**: `"/^Authorization$/i"` uses full regex matching - -**Usage**: -```php -$filtered = HeaderFilter::filter($headers, ['X-Debug-*', 'Authorization']); -``` - -**Pattern detection**: -- Patterns starting with `/` and ending with `/` or `/i`: Treated as regex -- Patterns containing `*`: Treated as wildcards (converted to regex) -- All other patterns: Exact match (case-insensitive) - -### ErrorTypeTranslator (`src/Helpers/ErrorTypeTranslator.php`) - -**Purpose**: Translates PHP error type integers to human-readable string constants. - -**Supported error types**: -- Fatal errors: `E_ERROR`, `E_CORE_ERROR`, `E_COMPILE_ERROR`, `E_USER_ERROR` -- Warnings: `E_WARNING`, `E_CORE_WARNING`, `E_COMPILE_WARNING`, `E_USER_WARNING` -- Parse errors: `E_PARSE` -- Notices: `E_NOTICE`, `E_USER_NOTICE` -- Strict standards: `E_STRICT` -- Recoverable errors: `E_RECOVERABLE_ERROR` -- Deprecations: `E_DEPRECATED`, `E_USER_DEPRECATED` -- All errors: `E_ALL` - -**Usage**: -```php -$errorString = ErrorTypeTranslator::translateErrorType(E_WARNING); // Returns 'E_WARNING' -``` - -**Unknown types**: Returns `'UNKNOWN'` for unrecognized error type constants. - -## Documentation Standards - -All classes in the codebase follow comprehensive PHPDoc standards: - -### Class-level Documentation -- Purpose and responsibility -- Key features and capabilities -- Usage notes and examples -- Special behaviors or limitations -- Package declaration - -### Method Documentation -- Clear description of what the method does -- All parameters with types and descriptions -- Return type and description -- Thrown exceptions (when applicable) -- Usage examples for complex methods - -### Property Documentation -- Type declarations -- Purpose and usage -- Default values when relevant - -### Code Examples -- Use `` tags instead of triple backticks in PHPDoc to avoid parse errors -- Keep examples concise and focused -- Show real-world usage patterns - -## Notes for Claude - -- When making changes, always run `composer pint` before committing -- Maintain strict type declarations and final classes -- Follow the provider pattern for extensibility -- Keep field masking comprehensive for security -- Test with both debug mode ON and OFF -- Consider backward compatibility when modifying factory parameters -- Use named arguments for better readability in factory methods -- All helper classes use static methods for stateless operations -- Use `readonly` properties where applicable (PHP 8.1+) -- Maintain comprehensive PHPDoc blocks for all classes, methods, and properties -- When documenting regex patterns in PHPDoc, use `` tags instead of ``` to avoid parse errors -- Follow PSR-12 strictly - Laravel Pint enforces this automatically diff --git a/composer.json b/composer.json index f08b01f..aaa4f60 100644 --- a/composer.json +++ b/composer.json @@ -1,53 +1,59 @@ { - "type": "library", - "name": "treblle/treblle-php", - "description": "Stay in tune with your APIs", - "homepage": "https://treblle.com/", - "keywords": [ - "treblle", - "api", - "monitoring", - "debuging", - "documentation", - "phpunit" - ], - "license": "MIT", - "authors": [ - { - "name": "Bhushan Gaikwad", - "email": "bhushan@treblle.com", - "homepage": "https://treblle.com/", - "role": "Developer" + "name": "treblle/treblle-php", + "description": "Runtime Intelligence Platform", + "type": "library", + "license": "MIT", + "homepage": "https://treblle.com/", + "keywords": [ + "treblle", + "intelligence", + "runtime", + "monitoring", + "observability", + "symfony", + "php", + "analytics", + "governance", + "logs" + ], + "authors": [ + { + "role": "Developer", + "name": "Vedran Cindric", + "email": "vedran@treblle.com", + "homepage": "https://treblle.com/" + } + ], + "require": { + "php": "^8.2", + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "ext-zlib": "*", + "psr/http-message": "^1.1 || ^2.0", + "psr/http-server-handler": "^1.0", + "psr/http-server-middleware": "^1.0" }, - { - "name": "Vedran Cindrić", - "email": "vedran@treblle.com", - "homepage": "https://treblle.com/", - "role": "Developer" - } - ], - "autoload": { - "psr-4": { - "Treblle\\Php\\": "src/" - } - }, - "require": { - "php": "^8.2", - "ext-mbstring": "*", - "guzzlehttp/guzzle": "^7.4.5 || ^8.0 || ^9.0" - }, - "require-dev": { - "laravel/pint": "^1.15" - }, - "config": { - "preferred-install": "dist", - "sort-packages": true - }, - "scripts": { - "pint": [ - "./vendor/bin/pint" - ] - }, - "minimum-stability": "stable", - "prefer-stable": true + "require-dev": { + "laravel/pint": "^1.29", + "phpstan/phpstan": "^2.2", + "phpunit/phpunit": "^11.0" + }, + "autoload": { + "psr-4": { + "Treblle\\Php\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Treblle\\Php\\Tests\\": "tests/" + } + }, + "scripts": { + "test": "phpunit", + "analyse": "phpstan analyse", + "format": "pint" + }, + "minimum-stability": "stable", + "prefer-stable": true } diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..02eb9b8 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,6 @@ +parameters: + level: 9 + paths: + - src + excludePaths: + - vendor diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..bf62396 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,17 @@ + + + + + tests/Unit + + + + + src + + + diff --git a/pint.json b/pint.json index c8f6576..8858519 100644 --- a/pint.json +++ b/pint.json @@ -1,99 +1,19 @@ { - "preset": "psr12", - "rules": { - "align_multiline_comment": true, - "array_indentation": true, - "array_syntax": true, - "blank_line_between_import_groups": false, - "blank_lines_before_namespace": true, - "blank_line_before_statement": true, - "blank_line_after_namespace": true, - "blank_line_after_opening_tag": true, - "no_extra_blank_lines": true, - "combine_consecutive_issets": true, - "combine_consecutive_unsets": true, - "single_import_per_statement": true, - "single_blank_line_at_eof": true, - "final_public_method_for_abstract_class": false, - "no_trailing_whitespace": true, - "concat_space": { - "spacing": "one" - }, - "final_class": true, - "declare_parentheses": true, - "declare_strict_types": true, - "explicit_string_variable": true, - "fully_qualified_strict_types": true, - "global_namespace_import": { - "import_classes": true, - "import_constants": true, - "import_functions": true - }, - "is_null": true, - "no_unused_imports": true, - "lambda_not_used_import": true, - "logical_operators": true, - "method_chaining_indentation": true, - "modernize_strpos": true, - "trailing_comma_in_multiline": true, - "new_with_braces": true, - "no_empty_comment": true, - "not_operator_with_successor_space": true, - "ordered_traits": true, - "protected_to_private": false, - "simplified_if_return": true, - "strict_comparison": true, - "ternary_to_null_coalescing": true, - "trim_array_spaces": true, - "use_arrow_functions": true, - "void_return": true, - "yoda_style": true, - "array_push": true, - "assign_null_coalescing_to_coalesce_equal": true, - "explicit_indirect_variable": true, - "method_argument_space": { - "on_multiline": "ensure_fully_multiline" - }, - "modernize_types_casting": true, - "no_superfluous_elseif": true, - "no_useless_else": true, - "nullable_type_declaration_for_default_null_value": true, - "ordered_imports": { - "sort_algorithm": "length" - }, - "ordered_class_elements": { - "order": [ - "use_trait", - "case", - "constant", - "constant_public", - "constant_protected", - "constant_private", - "property_public", - "property_protected", - "property_private", - "construct", - "destruct", - "magic", - "phpunit", - "method_abstract", - "method_public_static", - "method_public", - "method_protected_static", - "method_protected", - "method_private_static", - "method_private" - ], - "sort_algorithm": "none" - }, - "class_attributes_separation": { - "elements": { - "const": "none", - "method": "one", - "property": "one", - "trait_import": "none", - "case": "none" - } + "preset": "psr12", + "rules": { + "array_syntax": { "syntax": "short" }, + "ordered_imports": { "sort_algorithm": "alpha" }, + "no_unused_imports": true, + "not_operator_with_successor_space": true, + "trailing_comma_in_multiline": true, + "phpdoc_scalar": true, + "unary_operator_spaces": true, + "binary_operator_spaces": true, + "blank_line_before_statement": { + "statements": ["break", "continue", "declare", "return", "throw", "try"] + }, + "phpdoc_single_line_var_spacing": true, + "phpdoc_var_without_name": true, + "single_trait_insert_per_statement": true } - } } diff --git a/src/CircuitBreaker/CircuitBreaker.php b/src/CircuitBreaker/CircuitBreaker.php new file mode 100644 index 0000000..4dc7f78 --- /dev/null +++ b/src/CircuitBreaker/CircuitBreaker.php @@ -0,0 +1,141 @@ +prefix = 'treblle_cb_' . substr(md5($endpoint), 0, 8) . '_'; + } + + public static function create(string $endpoint): self + { + $storage = function_exists('apcu_enabled') && apcu_enabled() + ? new ApcuStorage() + : new FileStorage(); + + return new self($storage, $endpoint); + } + + /** + * Returns true when the circuit is open and the caller should skip the request. + * Returns false (allow through) when: + * - The circuit is closed (no failures recorded). + * - The backoff period has expired AND this caller won the half-open probe + * slot. All other concurrent callers are blocked until the probe records + * its result, preventing a thundering herd on recovery. + * + * The 'until' key stores an absolute timestamp as its value (not a TTL flag) + * so the transition from OPEN → HALF-OPEN is controlled explicitly here + * rather than by storage expiry — allowing exactly one probe worker through. + */ + public function isOpen(): bool + { + $until = $this->storage->get($this->prefix . 'until'); + + if ($until === null) { + return false; + } + + if (time() < $until) { + return true; + } + + // Backoff expired — exactly one worker gets the probe slot. + return ! $this->storage->setIfAbsent($this->prefix . 'probe', 1, self::PROBE_TTL); + } + + /** + * A successful response closes the circuit and resets all state. + */ + public function recordSuccess(): void + { + $this->storage->delete( + $this->prefix . 'count', + $this->prefix . 'until', + $this->prefix . 'probe', + ); + } + + /** + * Records a failed response and opens the circuit when appropriate. + * + * - 429: opens immediately for Retry-After seconds (or DEFAULT_RETRY_AFTER). + * - 5xx / 0 (network error): increments the failure counter; opens once + * FAILURE_THRESHOLD is reached, with exponential backoff. + * - 4xx (non-429): config-level errors that waiting cannot fix — not circuit-broken. + */ + public function recordFailure(int $httpCode, ?int $retryAfterSeconds = null): void + { + if ($httpCode === 429) { + $seconds = $this->jitter($retryAfterSeconds ?? self::DEFAULT_RETRY_AFTER); + $this->storage->set( + $this->prefix . 'until', + time() + $seconds, + $seconds + self::UNTIL_TTL_SLACK, + ); + + return; + } + + if ($httpCode >= 500 || $httpCode === 0) { + $count = $this->storage->increment($this->prefix . 'count'); + + if ($count >= self::FAILURE_THRESHOLD) { + $exponent = $count - self::FAILURE_THRESHOLD; + $base = (int) min(self::BASE_BACKOFF * (2 ** $exponent), self::MAX_BACKOFF); + $backoff = $this->jitter($base); + $this->storage->set( + $this->prefix . 'until', + time() + $backoff, + $backoff + self::UNTIL_TTL_SLACK, + ); + // Clear any existing probe slot so the new backoff gets a fresh probe. + $this->storage->delete($this->prefix . 'probe'); + } + } + // 4xx (non-429): no action. + } + + private function jitter(int $seconds): int + { + $range = (int) round($seconds * self::JITTER_FACTOR); + if ($range === 0) { + return $seconds; + } + + return $seconds + random_int(-$range, $range); + } +} diff --git a/src/CircuitBreaker/Storage/ApcuStorage.php b/src/CircuitBreaker/Storage/ApcuStorage.php new file mode 100644 index 0000000..b59bd39 --- /dev/null +++ b/src/CircuitBreaker/Storage/ApcuStorage.php @@ -0,0 +1,51 @@ +path = $path ?? sys_get_temp_dir() . '/treblle_circuit_breaker.json'; + } + + public function increment(string $key): int + { + $result = $this->withLock(function (array &$data) use ($key): int { + $current = isset($data[$key]['val']) && is_int($data[$key]['val']) + ? $data[$key]['val'] + : 0; + $data[$key] = ['val' => $current + 1]; + + return $current + 1; + }); + + return is_int($result) ? $result : 1; + } + + public function set(string $key, int $value, int $ttl): void + { + $this->withLock(function (array &$data) use ($key, $value, $ttl): int { + $data[$key] = ['val' => $value, 'exp' => time() + $ttl]; + + return 0; + }); + } + + public function setIfAbsent(string $key, int $value, int $ttl): bool + { + $result = $this->withLock(function (array &$data) use ($key, $value, $ttl): bool { + if (isset($data[$key])) { + $exp = isset($data[$key]['exp']) && is_int($data[$key]['exp']) + ? $data[$key]['exp'] + : null; + if ($exp === null || $exp > time()) { + return false; + } + } + + $data[$key] = ['val' => $value, 'exp' => time() + $ttl]; + + return true; + }); + + return is_bool($result) ? $result : false; + } + + public function get(string $key): ?int + { + $result = $this->withLock(function (array &$data) use ($key): ?int { + if (! isset($data[$key])) { + return null; + } + + $exp = isset($data[$key]['exp']) && is_int($data[$key]['exp']) + ? $data[$key]['exp'] + : null; + + if ($exp !== null && $exp < time()) { + unset($data[$key]); + + return null; + } + + $val = $data[$key]['val'] ?? null; + + return is_int($val) ? $val : null; + }); + + return is_int($result) ? $result : null; + } + + public function delete(string ...$keys): void + { + $this->withLock(function (array &$data) use ($keys): int { + foreach ($keys as $key) { + unset($data[$key]); + } + + return 0; + }); + } + + private function withLock(callable $fn): mixed + { + $fp = @fopen($this->path, 'c+'); + if ($fp === false) { + return null; + } + + try { + flock($fp, LOCK_EX); + $content = stream_get_contents($fp); + $decoded = ($content !== '' && $content !== false) + ? json_decode($content, true) + : null; + $data = is_array($decoded) ? $decoded : []; + + $result = $fn($data); + + fseek($fp, 0); + ftruncate($fp, 0); + fwrite($fp, (string) json_encode($data)); + + return $result; + } finally { + flock($fp, LOCK_UN); + fclose($fp); + } + } +} diff --git a/src/CircuitBreaker/Storage/StorageInterface.php b/src/CircuitBreaker/Storage/StorageInterface.php new file mode 100644 index 0000000..14b7422 --- /dev/null +++ b/src/CircuitBreaker/Storage/StorageInterface.php @@ -0,0 +1,37 @@ +debug) { + if ($this->sdkToken === '') { + error_log('[TREBLLE] SDK token is missing'); + } + if ($this->apiKey === '') { + error_log('[TREBLLE] API key is missing'); + } + } + } + + public function getIngressUrl(): string + { + return $this->customIngress ?? self::INGRESS_URL; + } +} diff --git a/src/Contract/ErrorDataProvider.php b/src/Contract/ErrorDataProvider.php deleted file mode 100644 index a60624d..0000000 --- a/src/Contract/ErrorDataProvider.php +++ /dev/null @@ -1,17 +0,0 @@ - - */ - public function getErrors(): array; - - public function addError(Error $error): void; -} diff --git a/src/Contract/LanguageDataProvider.php b/src/Contract/LanguageDataProvider.php deleted file mode 100644 index 2c3aa8e..0000000 --- a/src/Contract/LanguageDataProvider.php +++ /dev/null @@ -1,12 +0,0 @@ - */ + private array $errors = []; + + public function addError( + string $source, + string $type, + string $message, + string $file, + int $line + ): void { + if (count($this->errors) >= self::MAX_ERRORS) { + return; + } + + $this->errors[] = compact('source', 'type', 'message', 'file', 'line'); + } + + /** @return array */ + public function getErrors(): array + { + return $this->errors; + } +} diff --git a/src/DataCollector/LanguageCollector.php b/src/DataCollector/LanguageCollector.php new file mode 100644 index 0000000..9421f61 --- /dev/null +++ b/src/DataCollector/LanguageCollector.php @@ -0,0 +1,17 @@ + 'php', + 'version' => PHP_VERSION, + ]; + } +} diff --git a/src/DataCollector/RequestCollector.php b/src/DataCollector/RequestCollector.php new file mode 100644 index 0000000..36c23f4 --- /dev/null +++ b/src/DataCollector/RequestCollector.php @@ -0,0 +1,270 @@ +, body: array, route_path: string|null, query: array}|null */ + private ?array $cached = null; + + public static function setRoutePath(string $path): void + { + self::$overriddenRoutePath = $path; + } + + public static function getRoutePath(): ?string + { + return self::$overriddenRoutePath; + } + + public static function reset(): void + { + self::$overriddenRoutePath = null; + } + + /** + * @return array{ + * timestamp: string, + * ip: string, + * url: string, + * user_agent: string, + * method: string, + * headers: array, + * body: array, + * route_path: string|null, + * query: array + * } + */ + public function collect(): array + { + if ($this->cached !== null) { + return $this->cached; + } + + $data = [ + 'timestamp' => gmdate('Y-m-d H:i:s'), + 'ip' => $this->resolveClientIp(), + 'url' => $this->resolveUrl(), + 'user_agent' => $this->serverString('HTTP_USER_AGENT'), + 'method' => strtoupper($this->serverString('REQUEST_METHOD', 'GET')), + 'headers' => $this->collectHeaders(), + 'body' => $this->parseBody(), + 'route_path' => $this->resolveRoutePath(), + 'query' => $this->collectQuery(), + ]; + + $this->cached = $data; + + return $data; + } + + private function resolveClientIp(): string + { + if (! empty($_SERVER['HTTP_CLIENT_IP'])) { + return $this->firstIp($this->serverString('HTTP_CLIENT_IP')); + } + + if (! empty($_SERVER['HTTP_X_FORWARDED_FOR'])) { + return $this->firstIp($this->serverString('HTTP_X_FORWARDED_FOR')); + } + + $remoteAddr = $this->serverString('REMOTE_ADDR'); + if ($remoteAddr !== '') { + return $remoteAddr; + } + + return 'bogon'; + } + + private function firstIp(string $value): string + { + return trim(explode(',', $value)[0]); + } + + private function resolveUrl(): string + { + $https = $this->serverString('HTTPS'); + $scheme = ($https !== '' && $https !== 'off') ? 'https' : 'http'; + $host = $this->serverString('HTTP_HOST') + ?: $this->serverString('SERVER_NAME', 'localhost'); + $uri = $this->serverString('REQUEST_URI', '/'); + + return $scheme . '://' . $host . $uri; + } + + /** @return array */ + private function collectHeaders(): array + { + $headers = []; + + if (function_exists('getallheaders')) { + foreach (getallheaders() as $name => $value) { + $headers[$name] = $value; + } + + return $headers; + } + + foreach ($_SERVER as $key => $value) { + if (! is_string($key) || ! is_string($value)) { + continue; + } + + if (str_starts_with($key, 'HTTP_')) { + $name = str_replace('_', '-', ucwords(strtolower(substr($key, 5)), '_')); + $headers[$name] = $value; + } elseif (in_array($key, ['CONTENT_TYPE', 'CONTENT_LENGTH'], true)) { + $name = str_replace('_', '-', ucwords(strtolower($key), '_')); + $headers[$name] = $value; + } + } + + return $headers; + } + + /** @return array */ + private function collectQuery(): array + { + $query = []; + + foreach ($_GET as $key => $value) { + $query[$key] = match (true) { + is_string($value) => $value, + is_array($value) => implode(',', array_map( + static fn (mixed $v): string => is_string($v) ? $v : '', + $value + )), + is_int($value), is_float($value) => (string) $value, + default => '', + }; + } + + return $query; + } + + /** @return array */ + private function parseBody(): array + { + $contentType = strtolower($this->serverString('CONTENT_TYPE')); + + if (! empty($_FILES)) { + return $this->buildFileUploadInfo(); + } + + // Reject oversized bodies before reading — Content-Length is advisory but avoids + // allocating a large string just to discard it. The capped read below is the hard guard. + $contentLength = (int) $this->serverString('CONTENT_LENGTH', '0'); + if ($contentLength > self::MAX_BODY_BYTES) { + return ['error' => 'Payload exceeds 2MB', 'size' => $contentLength]; + } + + // Read at most MAX+1 bytes so we can detect oversized bodies without pulling + // the entire stream into memory first. + $rawInput = file_get_contents('php://input', false, null, 0, self::MAX_BODY_BYTES + 1) ?: ''; + $size = mb_strlen($rawInput, '8bit'); + + if ($size > self::MAX_BODY_BYTES) { + return ['error' => 'Payload exceeds 2MB', 'size' => $size]; + } + + if (str_contains($contentType, 'application/json')) { + if ($rawInput === '') { + return []; + } + + $decoded = $this->jsonDecodeArray($rawInput); + + return $decoded ?? ['error' => 'Request payload is not valid JSON']; + } + + if (str_contains($contentType, 'application/x-www-form-urlencoded')) { + return $_POST; + } + + if ($rawInput !== '') { + $decoded = $this->jsonDecodeArray($rawInput); + + return $decoded ?? ['error' => 'Request payload is not valid JSON']; + } + + return []; + } + + /** @return array */ + private function buildFileUploadInfo(): array + { + $uploads = []; + + foreach ($_FILES as $file) { + if (! is_array($file)) { + continue; + } + + $name = $file['name'] ?? null; + + if (is_array($name)) { + foreach ($name as $i => $fileName) { + $uploads[] = [ + 'name' => $fileName, + 'type' => is_array($file['type'] ?? null) ? ($file['type'][$i] ?? null) : null, + 'size' => is_array($file['size'] ?? null) ? ($file['size'][$i] ?? null) : null, + ]; + } + } else { + $uploads[] = [ + 'name' => $name, + 'type' => $file['type'] ?? null, + 'size' => $file['size'] ?? null, + ]; + } + } + + return ['_treblle_file_upload' => count($uploads) === 1 ? $uploads[0] : $uploads]; + } + + /** + * Decodes a JSON string to a string-keyed array. + * JSON object keys are always strings, but PHPStan types json_decode as mixed. + * + * @return array|null + */ + private function jsonDecodeArray(string $json): ?array + { + $data = json_decode($json, true); + + if (! is_array($data)) { + return null; + } + + $result = []; + foreach ($data as $key => $value) { + $result[(string) $key] = $value; + } + + return $result; + } + + private function resolveRoutePath(): ?string + { + if (self::$overriddenRoutePath !== null) { + return self::$overriddenRoutePath; + } + + $serverVar = $_SERVER['TREBLLE_ROUTE_PATH'] ?? null; + + return is_string($serverVar) && $serverVar !== '' ? $serverVar : null; + } + + private function serverString(string $key, string $default = ''): string + { + $value = $_SERVER[$key] ?? null; + + return is_string($value) ? $value : $default; + } +} diff --git a/src/DataCollector/ResponseCollector.php b/src/DataCollector/ResponseCollector.php new file mode 100644 index 0000000..2937923 --- /dev/null +++ b/src/DataCollector/ResponseCollector.php @@ -0,0 +1,114 @@ +, + * code: int, + * size: int, + * load_time: float, + * body: array, + * content_type: string|null + * } + */ + public function collect(): array + { + $rawBody = ob_get_contents() ?: ''; + $size = mb_strlen($rawBody, '8bit'); + $headers = $this->collectHeaders(); + $contentType = $this->extractContentType($headers); + + $body = $this->parseBody($rawBody, $size); + $loadTime = $this->calculateLoadTime(); + + return [ + 'headers' => $headers, + 'code' => $this->resolveStatusCode(), + 'size' => $size, + 'load_time' => $loadTime, + 'body' => $body, + 'content_type' => $contentType, + ]; + } + + private function resolveStatusCode(): int + { + $code = http_response_code(); + + return is_int($code) && $code > 0 ? $code : 200; + } + + /** @return array */ + private function collectHeaders(): array + { + $headers = []; + + foreach (headers_list() as $header) { + $pos = strpos($header, ':'); + if ($pos === false) { + continue; + } + + $name = trim(substr($header, 0, $pos)); + $value = trim(substr($header, $pos + 1)); + $headers[$name] = $value; + } + + return $headers; + } + + /** @param array $headers */ + private function extractContentType(array $headers): ?string + { + foreach ($headers as $name => $value) { + if (strtolower($name) === 'content-type') { + return $value; + } + } + + return null; + } + + /** @return array */ + private function parseBody(string $rawBody, int $size): array + { + if ($rawBody === '') { + return []; + } + + if ($size > self::MAX_BODY_BYTES) { + return ['error' => 'Payload exceeds 2MB', 'size' => $size]; + } + + $data = json_decode($rawBody, true); + + if (! is_array($data)) { + return ['error' => 'Response payload is not valid JSON']; + } + + $result = []; + foreach ($data as $key => $value) { + $result[(string) $key] = $value; + } + + return $result; + } + + private function calculateLoadTime(): float + { + $start = $_SERVER['REQUEST_TIME_FLOAT'] ?? null; + + if (! is_float($start) && ! is_int($start)) { + return 0.0; + } + + return round((microtime(true) - $start) * 1000, 2); + } +} diff --git a/src/DataCollector/ServerCollector.php b/src/DataCollector/ServerCollector.php new file mode 100644 index 0000000..6dbfa8a --- /dev/null +++ b/src/DataCollector/ServerCollector.php @@ -0,0 +1,68 @@ + $this->resolveServerIp(), + 'timezone' => date_default_timezone_get() ?: 'UTC', + 'software' => $this->serverStringOrNull('SERVER_SOFTWARE'), + 'protocol' => $this->serverStringOrNull('SERVER_PROTOCOL'), + 'os' => $this->resolveOs(), + ]; + } + + private function resolveServerIp(): string + { + if (self::$cachedIp !== null) { + return self::$cachedIp; + } + + $addr = $this->serverStringOrNull('SERVER_ADDR'); + if ($addr !== null) { + return self::$cachedIp = $addr; + } + + $hostname = gethostname(); + if ($hostname !== false) { + $ip = gethostbyname($hostname); + if ($ip !== $hostname) { + return self::$cachedIp = $ip; + } + } + + return self::$cachedIp = 'bogon'; + } + + /** @return array{name: string|null, release: string|null, architecture: string|null} */ + private function resolveOs(): array + { + if (self::$cachedOs !== null) { + return self::$cachedOs; + } + + return self::$cachedOs = [ + 'name' => php_uname('s') ?: null, + 'release' => php_uname('r') ?: null, + 'architecture' => php_uname('m') ?: null, + ]; + } + + private function serverStringOrNull(string $key): ?string + { + $value = $_SERVER[$key] ?? null; + + return is_string($value) && $value !== '' ? $value : null; + } +} diff --git a/src/DataProviders/InMemoryErrorDataProvider.php b/src/DataProviders/InMemoryErrorDataProvider.php deleted file mode 100644 index c30a874..0000000 --- a/src/DataProviders/InMemoryErrorDataProvider.php +++ /dev/null @@ -1,66 +0,0 @@ - In-memory storage for errors - */ - private array $errors = []; - - /** - * Gets all errors collected during the request. - * - * Returns all Error objects that have been added via addError() - * during the current request lifecycle. - * - * @return list Array of Error objects - */ - public function getErrors(): array - { - return $this->errors; - } - - /** - * Adds an error to the in-memory collection. - * - * Called by Treblle's error handlers when errors, exceptions, - * or shutdown errors occur. Errors are stored in the order they - * are encountered. Stops accepting errors after MAX_ERRORS to - * prevent unbounded memory growth in noisy applications. - * - * @param Error $error The error object to store - * @return void - */ - public function addError(Error $error): void - { - if (count($this->errors) >= self::MAX_ERRORS) { - return; - } - - $this->errors[] = $error; - } -} diff --git a/src/DataProviders/OutputBufferingResponseDataProvider.php b/src/DataProviders/OutputBufferingResponseDataProvider.php deleted file mode 100644 index e1c0fa3..0000000 --- a/src/DataProviders/OutputBufferingResponseDataProvider.php +++ /dev/null @@ -1,204 +0,0 @@ -2MB) and invalid JSON - * - * @package Treblle\Php\DataProviders - */ -final readonly class OutputBufferingResponseDataProvider implements ResponseDataProvider -{ - /** - * Constructs a new OutputBufferingResponseDataProvider. - * - * @param SensitiveDataMasker $fieldMasker The data masker for sensitive fields - * @param ErrorDataProvider $errorDataProvider Error provider for logging issues - * @param list $excludedHeaders Header patterns to exclude from Treblle - * @throws RuntimeException If output buffering is not enabled - */ - public function __construct( - private SensitiveDataMasker $fieldMasker, - private ErrorDataProvider $errorDataProvider, - private array $excludedHeaders = [] - ) { - if (ob_get_level() < 1) { - throw new RuntimeException('Output buffering must be enabled to collect responses. Have you called `ob_start()`?'); - } - } - - /** - * Gets HTTP response data from output buffer and headers. - * - * Collects and processes: - * - HTTP status code (defaults to 200 if not set) - * - Response size in bytes from output buffer - * - Response load time in milliseconds - * - Response body as JSON (masked for sensitive fields) - * - Response headers (filtered) - * - * Handles edge cases: - * - Responses over 2MB: Logs error and returns empty body - * - Invalid JSON: Logs error and returns empty body - * - * @return Response The response data transfer object - */ - public function getResponse(): Response - { - $responseSize = ob_get_length() ?: 0; - $responseBody = $this->getResponseBody($responseSize); - - // Only mask if body is not empty - $responseBody = empty($responseBody) ? [] : $this->fieldMasker->mask($responseBody); - - $responseCode = http_response_code() ?: null; - - $headers = $this->getResponseHeaders(); - - // Avoid function call if no headers to filter - $filteredHeaders = empty($this->excludedHeaders) - ? $headers - : HeaderFilter::filter($headers, $this->excludedHeaders); - - return new Response( - code: is_int($responseCode) ? $responseCode : 200, - size: $responseSize, - load_time: $this->getLoadTimeInMilliseconds(), - body: $responseBody, - headers: $filteredHeaders, - ); - } - - /** - * Gets response headers from headers_list(). - * - * Parses headers returned by headers_list() into a key-value array. - * Handles headers with colons in their values by only splitting on - * the first colon. - * - * @return array The response headers - */ - private function getResponseHeaders(): array - { - $headers = headers_list(); - - // Early return for empty headers - if (empty($headers)) { - return []; - } - - $data = []; - foreach ($headers as $header) { - // Split only on first colon for better performance - $pos = mb_strpos($header, ':'); - if (false !== $pos) { - $key = mb_substr($header, 0, $pos); - $value = trim(mb_substr($header, $pos + 1)); - $data[$key] = $value; - } - } - - return $data; - } - - /** - * Calculates the response load time in milliseconds. - * - * Measures the time between request start (REQUEST_TIME_FLOAT) and - * the current time using microtime(true). Returns 0 if REQUEST_TIME_FLOAT - * is not available. - * - * @return float The load time in milliseconds, or 0 if unavailable - */ - private function getLoadTimeInMilliseconds(): float - { - if (isset($_SERVER['REQUEST_TIME_FLOAT'])) { - return (microtime(true) * 1000) - ((float)$_SERVER['REQUEST_TIME_FLOAT'] * 1000); - } - - return 0.0000; - } - - /** - * Extracts and decodes the response body from output buffer. - * - * Handles special cases: - * - Responses >= 2MB: Logs an error and returns empty array - * - Invalid JSON: Logs an error and returns empty array - * - Non-string output: Returns empty array - * - Empty responses: Returns empty array immediately - * - * Uses ob_get_flush() to retrieve buffered output and attempts - * to decode it as JSON. - * - * @param int $responseSize The size of the buffered output in bytes - * @return array The decoded response body or empty array - */ - private function getResponseBody(int $responseSize): array - { - // Early return for empty or oversized responses - if (0 === $responseSize) { - return []; - } - - if ($responseSize >= 2_000_000) { - $this->errorDataProvider->addError( - new Error( - 'JSON response size is over 2MB', - '', - 0, - 'onShutdown', - 'E_USER_ERROR', - ) - ); - - return []; - } - - try { - $output = ob_get_flush(); - if (! is_string($output) || '' === $output) { - return []; - } - - $decoded = json_decode($output, true); - - // Return empty array if JSON decoding failed or result is not an array - return is_array($decoded) ? $decoded : []; - } catch (Exception $exception) { - $this->errorDataProvider->addError( - new Error( - 'Invalid JSON format: ' . $exception->getMessage(), - '', - 0, - 'onShutdown', - 'INVALID_JSON', - ) - ); - } - - return []; - } -} diff --git a/src/DataProviders/PhpLanguageDataProvider.php b/src/DataProviders/PhpLanguageDataProvider.php deleted file mode 100644 index e32669e..0000000 --- a/src/DataProviders/PhpLanguageDataProvider.php +++ /dev/null @@ -1,37 +0,0 @@ - $excludedHeaders Header patterns to exclude from Treblle - */ - public function __construct( - private SensitiveDataMasker $masker, - private array $excludedHeaders = [] - ) { - } - - /** - * Gets HTTP request data from superglobals. - * - * Collects and processes: - * - Timestamp in UTC (Y-m-d H:i:s format) - * - Full request URL with protocol and query string - * - Client IP address (with proxy detection) - * - User-Agent header - * - HTTP method (GET, POST, etc.) - * - All request headers (filtered) - * - Request body data (masked for sensitive fields) - * - * @return Request The request data transfer object - */ - public function getRequest(): Request - { - // Avoid function call if no headers to filter - $headers = getallheaders() ?: []; - $filteredHeaders = empty($this->excludedHeaders) - ? $headers - : HeaderFilter::filter($headers, $this->excludedHeaders); - - return new Request( - timestamp: gmdate('Y-m-d H:i:s'), - url: $this->getEndpointUrl(), - ip: $this->getClientIpAddress(), - user_agent: $_SERVER['HTTP_USER_AGENT'] ?? '', - method: $_SERVER['REQUEST_METHOD'] ?? 'GET', - headers: $filteredHeaders, - query: $this->masker->mask($_GET), - body: $this->masker->mask($this->getRequestBody()), - ); - } - - /** - * Gets the request body from php://input. - * - * Reads the raw request body and attempts to JSON-decode it. - * Falls back to $_POST for form-encoded requests. - * - * @return array - */ - private function getRequestBody(): array - { - $rawBody = file_get_contents('php://input'); - - if ($rawBody !== false && $rawBody !== '') { - $decoded = json_decode($rawBody, true); - - if (is_array($decoded)) { - return $decoded; - } - } - - // Fallback to $_POST for form-encoded requests - if (! empty($_POST)) { - return $_POST; - } - - return []; - } - - /** - * Gets the client IP address with proxy detection. - * - * Attempts to detect the real client IP address by checking: - * 1. HTTP_CLIENT_IP header (if set) - * 2. HTTP_X_FORWARDED_FOR header (for proxied requests) - * 3. REMOTE_ADDR (direct connection) - * - * Defaults to 'bogon' if no IP address can be determined. - * - * @return string The client IP address or 'bogon' - */ - private function getClientIpAddress(): string - { - // Check in priority order with null coalescing for performance - return $_SERVER['HTTP_CLIENT_IP'] - ?? $_SERVER['HTTP_X_FORWARDED_FOR'] - ?? $_SERVER['REMOTE_ADDR'] - ?? 'bogon'; - } - - /** - * Constructs the complete request URL. - * - * Builds the full URL including: - * - Protocol (http:// or https:// based on HTTPS server variable) - * - Host from HTTP_HOST header - * - Request URI including path and query string - * - * Example: https://api.example.com/users?page=1 - * - * @return string The complete request URL - */ - private function getEndpointUrl(): string - { - // Optimized HTTPS detection - $isHttps = ! empty($_SERVER['HTTPS']) && 'off' !== $_SERVER['HTTPS']; - $protocol = $isHttps ? 'https://' : 'http://'; - - return $protocol . ($_SERVER['HTTP_HOST'] ?? '') . ($_SERVER['REQUEST_URI'] ?? ''); - } -} diff --git a/src/DataProviders/SuperGlobalsServerDataProvider.php b/src/DataProviders/SuperGlobalsServerDataProvider.php deleted file mode 100644 index 93ac0c7..0000000 --- a/src/DataProviders/SuperGlobalsServerDataProvider.php +++ /dev/null @@ -1,59 +0,0 @@ -getServerVariable('SERVER_ADDR') ?? 'bogon', - timezone: date_default_timezone_get(), - software: $this->getServerVariable('SERVER_SOFTWARE'), - protocol: $this->getServerVariable('SERVER_PROTOCOL'), - os: new Os( - php_uname('s'), - php_uname('r'), - php_uname('m'), - ), - ); - } - - /** - * Safely retrieves a server variable from the $_SERVER superglobal. - * - * @param string $variable The server variable name (e.g., 'SERVER_ADDR') - * @return string|null The variable value, or null if not set - */ - private function getServerVariable(string $variable): ?string - { - return $_SERVER[$variable] ?? null; - } -} diff --git a/src/DataTransferObject/Data.php b/src/DataTransferObject/Data.php deleted file mode 100644 index d2fce1d..0000000 --- a/src/DataTransferObject/Data.php +++ /dev/null @@ -1,97 +0,0 @@ - $errors List of errors that occurred during processing - */ - public function __construct( - private Server $server, - private Language $language, - private Request $request, - private Response $response, - private array $errors - ) { - } - - /** - * Gets the server information. - * - * @return Server - */ - public function getServer(): Server - { - return $this->server; - } - - /** - * Gets the language/runtime information. - * - * @return Language - */ - public function getLanguage(): Language - { - return $this->language; - } - - /** - * Gets the HTTP request data. - * - * @return Request - */ - public function getRequest(): Request - { - return $this->request; - } - - /** - * Gets the HTTP response data. - * - * @return Response - */ - public function getResponse(): Response - { - return $this->response; - } - - /** - * Gets the list of errors that occurred during request processing. - * - * @return list - */ - public function getErrors(): array - { - return $this->errors; - } - - /** - * Serializes the Data object to JSON format. - * - * @return array - */ - public function jsonSerialize(): array - { - return get_object_vars($this); - } -} diff --git a/src/DataTransferObject/Error.php b/src/DataTransferObject/Error.php deleted file mode 100644 index 431fe2b..0000000 --- a/src/DataTransferObject/Error.php +++ /dev/null @@ -1,105 +0,0 @@ -source; - } - - /** - * Gets the error type. - * - * For PHP, this typically includes error constants like E_ERROR, E_WARNING, - * E_NOTICE, or exception class names. Defaults to UNHANDLED_EXCEPTION if - * the specific type cannot be determined. - * - * @return string The error type identifier - */ - public function getType(): string - { - return $this->type; - } - - /** - * Gets the error message. - * - * @return string The error message text - */ - public function getMessage(): string - { - return $this->message; - } - - /** - * Gets the file path where the error occurred. - * - * @return string The absolute or relative file path - */ - public function getFile(): string - { - return $this->file; - } - - /** - * Gets the line number where the error occurred. - * - * @return int The line number in the file - */ - public function getLine(): int - { - return $this->line; - } - - /** - * Serializes the Error object to JSON format. - * - * @return array - */ - public function jsonSerialize(): array - { - return get_object_vars($this); - } -} diff --git a/src/DataTransferObject/Language.php b/src/DataTransferObject/Language.php deleted file mode 100644 index e3c72d2..0000000 --- a/src/DataTransferObject/Language.php +++ /dev/null @@ -1,67 +0,0 @@ -name; - } - - /** - * Gets the language version. - * - * Returns the version of the language runtime installed on the server. - * For PHP, this is typically pulled from PHP_VERSION constant. - * Example: 8.2.15, 8.3.0, 7.4.33 - * - * @return string|null The language version, or null if unavailable - */ - public function getVersion(): ?string - { - return $this->version; - } - - /** - * Serializes the Language object to JSON format. - * - * @return array - */ - public function jsonSerialize(): array - { - return get_object_vars($this); - } -} diff --git a/src/DataTransferObject/Os.php b/src/DataTransferObject/Os.php deleted file mode 100644 index 9dd1534..0000000 --- a/src/DataTransferObject/Os.php +++ /dev/null @@ -1,78 +0,0 @@ -name; - } - - /** - * Gets the operating system version/release. - * - * Examples: 5.4.0-42-generic, 10.0.19041, 21.6.0 - * - * @return string|null The OS version, or null if unavailable - */ - public function getRelease(): ?string - { - return $this->release; - } - - /** - * Gets the system architecture. - * - * Examples: x86_64, i386, arm64, aarch64 - * - * @return string|null The system architecture, or null if unavailable - */ - public function getArchitecture(): ?string - { - return $this->architecture; - } - - /** - * Serializes the Os object to JSON format. - * - * @return array - */ - public function jsonSerialize(): array - { - return get_object_vars($this); - } -} diff --git a/src/DataTransferObject/Request.php b/src/DataTransferObject/Request.php deleted file mode 100644 index 6994e30..0000000 --- a/src/DataTransferObject/Request.php +++ /dev/null @@ -1,167 +0,0 @@ - $headers The request headers - * @param array $query The query string parameters - * @param array $body The request body data - * @param string|null $route_path The route pattern (e.g., /api/v1/users/{id}) - */ - public function __construct( - private string $timestamp, - private string $url, - private string $ip = 'bogon', - private string $user_agent = '', - private string $method = 'GET', - private array $headers = [], - private array $query = [], - private array $body = [], - private ?string $route_path = null, - ) { - } - - /** - * Gets the request timestamp. - * - * The timestamp is generated when the request was received and is formatted - * as Y-m-d H:i:s in UTC timezone. - * - * @return string The request timestamp - */ - public function getTimestamp(): string - { - return $this->timestamp; - } - - /** - * Gets the client IP address. - * - * Returns the real IPv4 address of the client making the request. - * Defaults to 'bogon' for private/internal IP addresses. - * - * @return string The client IP address - */ - public function getIp(): string - { - return $this->ip; - } - - /** - * Gets the complete request URL. - * - * Includes the full URL with protocol, host, path, and query string. - * - * @return string The full request URL - */ - public function getUrl(): string - { - return $this->url; - } - - /** - * Gets the route pattern path. - * - * Used to group similar endpoints together in Treblle. For example, - * requests to /api/v1/users/123 and /api/v1/users/456 would both - * use the route path /api/v1/users/{id}. - * - * @return string|null The route pattern, or null if not available - */ - public function getRoutePath(): ?string - { - return $this->route_path; - } - - /** - * Gets the User-Agent header value. - * - * @return string The User-Agent string - */ - public function getUserAgent(): string - { - return $this->user_agent; - } - - /** - * Gets the HTTP request method. - * - * Common values: GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD - * Should be uppercase when possible. - * - * @return string The HTTP method (defaults to GET) - */ - public function getMethod(): string - { - return $this->method; - } - - /** - * Gets the request headers. - * - * Returns headers as a key-value array. Sensitive headers like - * Authorization may be masked by the field masker. - * - * @return array - */ - public function getHeaders(): array - { - return $this->headers; - } - - /** - * Gets the request body data. - * - * Includes all data sent with the request: form-data, x-www-form-urlencoded, - * JSON, XML, or raw data. Sensitive fields may be masked by the field masker. - * - * @return array - */ - public function getBody(): array - { - return $this->body; - } - - /** - * Gets the query string parameters. - * - * Returns all parameters from the URL query string as an associative array. - * - * @return array - */ - public function getQuery(): array - { - return $this->query; - } - - /** - * Serializes the Request object to JSON format. - * - * @return array - */ - public function jsonSerialize(): array - { - return get_object_vars($this); - } -} diff --git a/src/DataTransferObject/Response.php b/src/DataTransferObject/Response.php deleted file mode 100644 index 144f831..0000000 --- a/src/DataTransferObject/Response.php +++ /dev/null @@ -1,115 +0,0 @@ - $body The response body data - * @param array $headers The response headers - */ - public function __construct( - private int $code = 200, - private float $size = 0.0, - private float $load_time = 0.0, - private array $body = [], - private array $headers = [], - ) { - } - - /** - * Gets the response headers. - * - * Returns headers as a key-value array in JSON-compatible format. - * - * @return array - */ - public function getHeaders(): array - { - return $this->headers; - } - - /** - * Gets the HTTP status code. - * - * Common status codes: - * - 2xx: Success (200 OK, 201 Created, 204 No Content) - * - 3xx: Redirection (301 Moved Permanently, 302 Found) - * - 4xx: Client Error (400 Bad Request, 401 Unauthorized, 404 Not Found) - * - 5xx: Server Error (500 Internal Server Error, 503 Service Unavailable) - * - * @return int The HTTP status code (defaults to 200) - */ - public function getCode(): int - { - return $this->code; - } - - /** - * Gets the response size in bytes. - * - * Represents the total size of the response payload. Should be calculated - * using language-specific methods rather than relying solely on headers. - * - * @return float The response size in bytes - */ - public function getSize(): float - { - return $this->size; - } - - /** - * Gets the response load time. - * - * Represents the time taken to generate and return the response, - * measured in seconds with microsecond precision. Calculated as the - * difference between request start and response completion. - * - * @return float The load time in seconds - */ - public function getLoadTime(): float - { - return $this->load_time; - } - - /** - * Gets the response body data. - * - * Contains the complete response payload as returned by the server. - * This should be valid JSON-compatible data. Sensitive fields may - * be masked by the field masker. - * - * @return array - */ - public function getBody(): array - { - return $this->body; - } - - /** - * Serializes the Response object to JSON format. - * - * @return array - */ - public function jsonSerialize(): array - { - return get_object_vars($this); - } -} diff --git a/src/DataTransferObject/Server.php b/src/DataTransferObject/Server.php deleted file mode 100644 index 5e28483..0000000 --- a/src/DataTransferObject/Server.php +++ /dev/null @@ -1,110 +0,0 @@ -ip; - } - - /** - * Gets the server timezone. - * - * Returns the timezone in which the server is operating. - * Examples: UTC, America/New_York, Europe/Berlin, Asia/Tokyo - * Defaults to UTC if the timezone cannot be detected. - * - * @see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones - * @return string The server timezone - */ - public function getTimezone(): string - { - return $this->timezone; - } - - /** - * Gets the web server software. - * - * Examples: Apache, nginx, IIS, LiteSpeed, Caddy - * - * @return string|null The web server software, or null if unavailable - */ - public function getSoftware(): ?string - { - return $this->software; - } - - /** - * Gets the HTTP protocol version. - * - * Used to identify the HTTP version, particularly to detect HTTP/2 usage. - * Examples: HTTP/1.0, HTTP/1.1, HTTP/2, HTTP/3 - * - * @return string|null The HTTP protocol version, or null if unavailable - */ - public function getProtocol(): ?string - { - return $this->protocol; - } - - /** - * Gets the operating system information. - * - * @return Os The OS object containing name, release, and architecture - */ - public function getOs(): Os - { - return $this->os; - } - - /** - * Serializes the Server object to JSON format. - * - * @return array - */ - public function jsonSerialize(): array - { - return get_object_vars($this); - } -} diff --git a/src/ErrorTypeTranslator.php b/src/ErrorTypeTranslator.php new file mode 100644 index 0000000..2c7d5be --- /dev/null +++ b/src/ErrorTypeTranslator.php @@ -0,0 +1,30 @@ + 'E_ERROR', + E_WARNING => 'E_WARNING', + E_PARSE => 'E_PARSE', + E_NOTICE => 'E_NOTICE', + E_CORE_ERROR => 'E_CORE_ERROR', + E_CORE_WARNING => 'E_CORE_WARNING', + E_COMPILE_ERROR => 'E_COMPILE_ERROR', + E_COMPILE_WARNING => 'E_COMPILE_WARNING', + E_USER_ERROR => 'E_USER_ERROR', + E_USER_WARNING => 'E_USER_WARNING', + E_USER_NOTICE => 'E_USER_NOTICE', + E_RECOVERABLE_ERROR => 'E_RECOVERABLE_ERROR', + E_DEPRECATED => 'E_DEPRECATED', + E_USER_DEPRECATED => 'E_USER_DEPRECATED', + ]; + + public static function translate(int $type): string + { + return self::MAP[$type] ?? "Unknown: {$type}"; + } +} diff --git a/src/Factory/TreblleFactory.php b/src/Factory/TreblleFactory.php deleted file mode 100644 index 4fd2b7a..0000000 --- a/src/Factory/TreblleFactory.php +++ /dev/null @@ -1,156 +0,0 @@ - $maskedFields Additional fields to mask beyond defaults - * @param list $excludedHeaders Header patterns to exclude (exact, wildcard, or regex) - * @param array $config Advanced configuration options - * @return Treblle Configured Treblle instance ready to capture API data - */ - public static function create( - string $apiKey, - string $sdkToken, - bool $debug = false, - array $maskedFields = [], - array $excludedHeaders = [], - array $config = [] - ): Treblle { - $defaultMaskedFields = [ - 'password', - 'pwd', - 'secret', - 'password_confirmation', - 'cc', - 'card_number', - 'ccv', - 'ssn', - 'credit_score', - ]; - - $maskedFields = array_unique(array_merge($defaultMaskedFields, $maskedFields)); - - $masker = new SensitiveDataMasker($maskedFields); - - $errorDataProvider = new InMemoryErrorDataProvider(); - - $client = $config['client'] ?? self::createClient($config['url'] ?? null); - - $treblle = new Treblle( - apiKey: $apiKey, - sdkToken: $sdkToken, - client: $client, - serverDataProvider: $config['server_provider'] ?? new SuperGlobalsServerDataProvider(), - languageDataProvider: $config['language_provider'] ?? new PhpLanguageDataProvider(), - requestDataProvider: $config['request_provider'] ?? new SuperGlobalsRequestDataProvider($masker, $excludedHeaders), - responseDataProvider: $config['response_provider'] ?? new OutputBufferingResponseDataProvider($masker, $errorDataProvider, $excludedHeaders), - errorDataProvider: $config['error_provider'] ?? $errorDataProvider, - debug: $debug, - url: $config['url'] ?? null, - forkProcess: $config['fork_process'] ?? false, - ); - - if ($config['register_handlers'] ?? true) { - set_error_handler([$treblle, 'onError']); - set_exception_handler([$treblle, 'onException']); - register_shutdown_function([$treblle, 'onShutdown']); - } - - return $treblle; - } - - /** - * Creates a Guzzle HTTP client, optionally configured for connection reuse. - * - * When a custom URL is provided, the client is configured with TCP keep-alive - * via cURL options to enable connection reuse across requests. This benefits - * long-running PHP processes (Swoole, RoadRunner, FrankenPHP) where the same - * Treblle instance persists across multiple requests. - * - * cURL keep-alive settings: - * - CURLOPT_TCP_KEEPALIVE: Enables TCP keep-alive probes - * - CURLOPT_TCP_KEEPIDLE: 30 seconds before first keep-alive probe - * - CURLOPT_TCP_KEEPINTVL: 15 seconds between subsequent probes - * - * @param string|null $url Custom Treblle endpoint URL, or null for default rotation - * @return Client Configured Guzzle client instance - */ - private static function createClient(?string $url): Client - { - if (null === $url) { - return new Client(); - } - - return new Client([ - 'curl' => [ - CURLOPT_TCP_KEEPALIVE => 1, - CURLOPT_TCP_KEEPIDLE => 30, - CURLOPT_TCP_KEEPINTVL => 15, - ], - ]); - } -} diff --git a/src/Filter/PathMatcher.php b/src/Filter/PathMatcher.php new file mode 100644 index 0000000..4e20c76 --- /dev/null +++ b/src/Filter/PathMatcher.php @@ -0,0 +1,80 @@ + Compiled regex cache keyed by original pattern */ + private array $compiled = []; + + /** @param string[] $excludedPaths */ + public function __construct(private readonly array $excludedPaths) + { + } + + public function matches(string $requestPath): bool + { + foreach ($this->excludedPaths as $pattern) { + if ($this->patternMatches($pattern, $requestPath)) { + return true; + } + } + + return false; + } + + private function patternMatches(string $pattern, string $path): bool + { + $regex = $this->toRegex($pattern); + + return (bool) preg_match($regex, $path); + } + + private function toRegex(string $pattern): string + { + if (isset($this->compiled[$pattern])) { + return $this->compiled[$pattern]; + } + + if (count($this->compiled) >= 100) { + $this->compiled = []; + } + + // Detect regex patterns: /pattern/flags + if (str_starts_with($pattern, '/') && preg_match('/\/[gimsuy]*$/', $pattern)) { + return $this->compiled[$pattern] = $pattern; + } + + // Wildcard pattern: contains * or ? + if (str_contains($pattern, '*') || str_contains($pattern, '?')) { + return $this->compiled[$pattern] = $this->wildcardToRegex($pattern); + } + + // Exact match (case-insensitive) + return $this->compiled[$pattern] = '#^' . preg_quote($pattern, '#') . '$#i'; + } + + private function wildcardToRegex(string $pattern): string + { + $parts = preg_split('/(\*|\?)/', $pattern, -1, PREG_SPLIT_DELIM_CAPTURE); + + if ($parts === false) { + return '#^' . preg_quote($pattern, '#') . '$#i'; + } + + $regex = '#^'; + foreach ($parts as $part) { + if ($part === '*') { + $regex .= '.*'; + } elseif ($part === '?') { + $regex .= '.'; + } else { + $regex .= preg_quote($part, '#'); + } + } + + return $regex . '$#i'; + } +} diff --git a/src/Filter/RequestTypeFilter.php b/src/Filter/RequestTypeFilter.php new file mode 100644 index 0000000..601f121 --- /dev/null +++ b/src/Filter/RequestTypeFilter.php @@ -0,0 +1,32 @@ + 'E_ERROR', - E_WARNING => 'E_WARNING', - E_PARSE => 'E_PARSE', - E_NOTICE => 'E_NOTICE', - E_CORE_ERROR => 'E_CORE_ERROR', - E_CORE_WARNING => 'E_CORE_WARNING', - E_COMPILE_ERROR => 'E_COMPILE_ERROR', - E_COMPILE_WARNING => 'E_COMPILE_WARNING', - E_USER_ERROR => 'E_USER_ERROR', - E_USER_WARNING => 'E_USER_WARNING', - E_USER_NOTICE => 'E_USER_NOTICE', - E_STRICT => 'E_STRICT', - E_RECOVERABLE_ERROR => 'E_RECOVERABLE_ERROR', - E_DEPRECATED => 'E_DEPRECATED', - E_USER_DEPRECATED => 'E_USER_DEPRECATED', - default => 'Unknown: ' . (string)$type, - }; - } -} diff --git a/src/Helpers/HeaderFilter.php b/src/Helpers/HeaderFilter.php deleted file mode 100644 index ac38216..0000000 --- a/src/Helpers/HeaderFilter.php +++ /dev/null @@ -1,168 +0,0 @@ - Cache for compiled regex patterns - */ - private static array $regexCache = []; - - /** - * Filters headers by excluding those matching the provided patterns. - * - * Processes headers and removes any that match the exclusion patterns. - * Handles array values by extracting the first element. All pattern - * matching is case-insensitive by default. - * - * Optimized with regex pattern caching to avoid recompilation on repeated calls. - * - * Examples: - * - * // Exclude specific header - * HeaderFilter::filter($headers, ['X-Debug-Token']); - * - * // Exclude with wildcard - * HeaderFilter::filter($headers, ['X-Internal-*']); - * - * // Exclude with regex - * HeaderFilter::filter($headers, ['/^X-(Debug|Test)-.* /i']); - * - * - * @param array $headers The headers to filter (may contain array values) - * @param list $excludedHeaders The exclusion patterns (exact, wildcard, or regex) - * @return array The filtered headers with excluded ones removed - */ - public static function filter(array $headers, array $excludedHeaders = []): array - { - // Early return if no headers or no exclusions - if (empty($headers) || empty($excludedHeaders)) { - // Convert array values to strings - $processed = []; - foreach ($headers as $key => $value) { - $processed[$key] = is_array($value) ? (string) reset($value) : (string) $value; - } - - return $processed; - } - - $processed = []; - - foreach ($headers as $key => $value) { - // Get first value if array - $headerValue = is_array($value) ? reset($value) : $value; - - // Check if header matches any exclusion pattern - if (self::isExcluded($key, $excludedHeaders)) { - continue; - } - - $processed[$key] = $headerValue; - } - - return $processed; - } - - /** - * Checks if a header key matches any exclusion pattern. - * - * Iterates through all exclusion patterns and tests if the header key - * matches any of them using regex matching. - * - * @param string $key The header key to check - * @param list $excludedHeaders The list of exclusion patterns - * @return bool True if the header should be excluded, false otherwise - */ - private static function isExcluded(string $key, array $excludedHeaders): bool - { - foreach ($excludedHeaders as $pattern) { - $regex = self::convertPatternToRegex($pattern); - if (preg_match($regex, $key)) { - return true; - } - } - - return false; - } - - /** - * Converts a pattern to a regular expression with caching. - * - * Handles three types of patterns: - * 1. Regex patterns (already formatted): Returns as-is if wrapped in / / - * 2. Wildcard patterns: Converts * to .* and ? to . (e.g., "X-*" → "/^X-.*$/i") - * 3. Exact matches: Wraps in regex anchors with case-insensitive flag - * - * All non-regex patterns are converted to case-insensitive regex with - * anchors to ensure full string matching. - * - * Results are cached to avoid repeated compilation of the same patterns. - * - * @param string $pattern The pattern to convert (exact, wildcard, or regex) - * @return string A valid regular expression pattern - */ - private static function convertPatternToRegex(string $pattern): string - { - // Check cache first - if (isset(self::$regexCache[$pattern])) { - return self::$regexCache[$pattern]; - } - - // Already a regex pattern - if (preg_match('/^\/.*\/[gimxsu]*$/', $pattern)) { - self::cachePattern($pattern, $pattern); - - return $pattern; - } - - // Convert shell-style wildcards to regex - $regex = preg_quote($pattern, '/'); - $regex = str_replace(['\*', '\?'], ['.*', '.'], $regex); - $compiled = '/^' . $regex . '$/i'; - - self::cachePattern($pattern, $compiled); - - return $compiled; - } - - /** - * Stores a compiled regex pattern in the cache with size limits. - * - * Resets the cache when it exceeds MAX_CACHE_SIZE to prevent - * unbounded memory growth in long-running processes. - * - * @param string $pattern The original pattern as cache key - * @param string $regex The compiled regex to cache - * @return void - */ - private static function cachePattern(string $pattern, string $regex): void - { - if (count(self::$regexCache) >= self::MAX_CACHE_SIZE) { - self::$regexCache = []; - } - - self::$regexCache[$pattern] = $regex; - } -} diff --git a/src/Helpers/SensitiveDataMasker.php b/src/Helpers/SensitiveDataMasker.php deleted file mode 100644 index 5abab95..0000000 --- a/src/Helpers/SensitiveDataMasker.php +++ /dev/null @@ -1,221 +0,0 @@ - Lowercase field names as keys for O(1) lookup performance - */ - private array $lowerFields = []; - - /** - * Constructs a new SensitiveDataMasker. - * - * The field names provided are matched case-insensitively against - * keys in the data being masked. Field names are pre-processed and - * stored in a hash map for O(1) lookup performance. - * - * @param list $fields List of field names to mask (e.g., 'password', 'api_key') - */ - public function __construct( - public array $fields = [], - ) { - // Pre-process fields into lowercase hash map for fast lookups - foreach ($this->fields as $field) { - $this->lowerFields[mb_strtolower($field)] = true; - } - } - - /** - * Recursively masks sensitive data in the provided array. - * - * Processes each element in the array: - * - Arrays: Recursively masks nested data - * - Strings: Checks against masked fields, sensitive headers, and base64 images - * - Other types: Returns unchanged - * - * Optimized for performance with early returns and minimal memory allocations. - * - * Example: - * ```php - * $masker = new SensitiveDataMasker(['password', 'secret']); - * $data = [ - * 'username' => 'john', - * 'password' => 'secret123', - * 'profile' => ['secret' => 'hidden'] - * ]; - * $masked = $masker->mask($data); - * // Result: ['username' => 'john', 'password' => '*********', 'profile' => ['secret' => '******']] - * ``` - * - * @param array $data The data to mask - * @return array The masked data with sensitive values replaced - */ - public function mask(array $data): array - { - // Early return for empty arrays - if (empty($data)) { - return $data; - } - - $collector = []; - foreach ($data as $key => $value) { - $collector[$key] = match (true) { - is_array($value) => $this->mask( - data: $value, - ), - is_string($value) => $this->handleString( - key: $key, - value: $value, - ), - default => $value, - }; - } - - return $collector; - } - - /** - * Replaces a string with asterisks of the same length. - * - * Uses multibyte string length to correctly handle Unicode characters. - * This ensures that masked values maintain the same visual length as - * the original, which can be useful for debugging while maintaining security. - * - * Example: - * ```php - * $masker->star('password123'); // Returns: *********** - * $masker->star('café'); // Returns: **** - * ``` - * - * @param string $string The string to replace with asterisks - * @return string A string of asterisks with the same length as the input - */ - public function star(string $string): string - { - return str_repeat('*', mb_strlen($string)); - } - - /** - * Handles masking of string values based on field name and content. - * - * Applies masking logic in the following order: - * 1. Checks if field name matches configured sensitive fields (case-insensitive) - * 2. Checks if field is a sensitive header (authorization, x-api-key) - * 3. Checks if value is a base64-encoded image - * 4. Returns original value if no masking rules match - * - * Optimized with hash map lookups (O(1)) instead of array searches (O(n)). - * - * @param bool|float|int|string $key The field name/key (will be converted to string if needed) - * @param string $value The field value to potentially mask - * @return string The masked value or original value if no masking needed - */ - private function handleString(bool|float|int|string $key, string $value): string - { - if (! is_string($key)) { - $key = (string) $key; - } - - $lowerKey = mb_strtolower($key); - - // O(1) hash map lookup instead of O(n) array search - if (isset($this->lowerFields[$lowerKey])) { - return $this->star($value); - } - - if ($this->isSensitiveHeader($lowerKey)) { - return $this->maskAuthorization($value); - } - - if ($this->isBase64($value)) { - return 'base64 encoded images are too big to process'; - } - - return $value; - } - - /** - * Masks authorization header values while preserving the auth type. - * - * For recognized authorization schemes (Bearer, Basic, Digest), masks only - * the credential portion while keeping the scheme visible. This allows - * debugging of auth type issues while protecting the actual credentials. - * - * Examples: - * ```php - * maskAuthorization('Bearer eyJhbGc...') // Returns: 'Bearer **********' - * maskAuthorization('Basic dXNlcjp...') // Returns: 'Basic **********' - * maskAuthorization('CustomAuth xyz') // Returns: '***************' - * ``` - * - * @param string $value The authorization header value - * @return string The masked authorization value with scheme preserved (if recognized) - */ - private function maskAuthorization(string $value): string - { - $parts = explode(' ', $value, 2); - if (isset($parts[1])) { - $authTypeLower = mb_strtolower($parts[0]); - if (in_array($authTypeLower, ['bearer', 'basic', 'digest'], true)) { - return $parts[0] . ' ' . $this->star($parts[1]); - } - } - - return $this->star($value); - } - - /** - * Checks if a key represents a sensitive header. - * - * Currently detects: - * - authorization: OAuth tokens, Basic auth, etc. - * - x-api-key: Common API key header - * - * These headers are automatically masked regardless of the configured - * field list to ensure common authentication credentials are protected. - * - * @param string $key The header key in lowercase - * @return bool True if the header contains sensitive authentication data - */ - private function isSensitiveHeader(string $key): bool - { - return in_array($key, ['authorization', 'x-api-key'], true); - } - - /** - * Checks if a string is a base64-encoded image. - * - * Detects data URIs for images to avoid sending large base64-encoded - * images to Treblle, which can significantly increase payload size and - * provide little debugging value. - * - * Example detected format: "data:image/png;base64,iVBORw0KGgoAAAANSU..." - * - * @param string $string The string to check - * @return bool True if it matches the base64 image data URI pattern - */ - private function isBase64(string $string): bool - { - return str_starts_with($string, 'data:image/') && str_contains($string, ';base64,'); - } -} diff --git a/src/Http/IngressClient.php b/src/Http/IngressClient.php new file mode 100644 index 0000000..18d614f --- /dev/null +++ b/src/Http/IngressClient.php @@ -0,0 +1,107 @@ +circuitBreaker ??= CircuitBreaker::create($endpoint); + + if ($this->circuitBreaker->isOpen()) { + if ($debug) { + error_log('[TREBLLE] Circuit breaker open, skipping request'); + } + + return false; + } + + $ch = $this->getHandle(); + + $retryAfter = null; + + curl_setopt_array($ch, [ + CURLOPT_URL => $endpoint, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $gzippedBody, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CONNECTTIMEOUT => 3, + CURLOPT_TIMEOUT => 5, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, + CURLOPT_HTTPHEADER => [ + 'Content-Type: application/json', + 'Content-Encoding: gzip', + "x-api-key: {$sdkToken}", + ], + CURLOPT_HEADERFUNCTION => static function ($ch, string $header) use (&$retryAfter): int { + if (stripos($header, 'Retry-After:') === 0) { + $value = trim(substr($header, 12)); + if (is_numeric($value)) { + $retryAfter = (int) $value; + } + } + + return strlen($header); + }, + ]); + + $response = curl_exec($ch); + $curlError = curl_error($ch); + $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + + if ($debug) { + if ($curlError !== '') { + error_log("[TREBLLE] curl error: {$curlError}"); + } elseif ($httpCode >= 500) { + error_log("[TREBLLE] Received 5xx from Treblle ingress: {$httpCode}"); + } elseif ($httpCode >= 400) { + error_log("[TREBLLE] Received 4xx from Treblle ingress: {$httpCode}"); + } + } + + $success = $curlError === '' && $httpCode >= 200 && $httpCode < 300; + + if ($success) { + $this->circuitBreaker->recordSuccess(); + } else { + $networkError = $curlError !== '' ? 0 : $httpCode; + $this->circuitBreaker->recordFailure($networkError, $retryAfter); + } + + return $success; + } + + private function getHandle(): \CurlHandle + { + if ($this->handle !== null) { + curl_reset($this->handle); + + return $this->handle; + } + + $handle = curl_init(); + if ($handle === false) { + throw new \RuntimeException('[TREBLLE] Failed to initialize curl handle'); + } + + return $this->handle = $handle; + } + + public function __destruct() + { + $this->handle = null; + } +} diff --git a/src/Masking/SensitiveDataMasker.php b/src/Masking/SensitiveDataMasker.php new file mode 100644 index 0000000..27e1be6 --- /dev/null +++ b/src/Masking/SensitiveDataMasker.php @@ -0,0 +1,107 @@ + Lowercase hash map for O(1) keyword lookup */ + private readonly array $keywordMap; + + /** @param string[] $keywords */ + public function __construct(array $keywords) + { + $map = []; + foreach ($keywords as $kw) { + $map[strtolower($kw)] = true; + } + $this->keywordMap = $map; + } + + /** + * @param array $data + * @return array + */ + public function maskData(array $data): array + { + return $this->maskArray($data); + } + + /** + * @param array $headers + * @return array + */ + public function maskHeaders(array $headers): array + { + return $this->maskArray($headers, true); + } + + /** + * @param array $data + * @return array + */ + private function maskArray(array $data, bool $isHeaders = false): array + { + $result = []; + + foreach ($data as $key => $value) { + $stringKey = (string) $key; + $lowerKey = strtolower($stringKey); + + if (isset($this->keywordMap[$lowerKey])) { + $result[$stringKey] = $isHeaders + ? $this->maskHeaderValue($lowerKey, is_string($value) ? $value : '') + : $this->maskValue($value); + + continue; + } + + if (is_array($value)) { + $result[$stringKey] = $this->maskArray($value, $isHeaders); + } else { + $result[$stringKey] = $this->maybeRedactImage($value); + } + } + + return $result; + } + + private function maskHeaderValue(string $headerName, string $value): string + { + if ($headerName === 'authorization') { + $parts = explode(' ', $value, 2); + if (count($parts) === 2) { + return $parts[0] . ' ' . str_repeat('*', mb_strlen($parts[1])); + } + } + + return str_repeat('*', mb_strlen($value)); + } + + private function maskValue(mixed $value): mixed + { + if (is_string($value)) { + return str_repeat('*', mb_strlen($value)); + } + + if (is_int($value) || is_float($value)) { + return str_repeat('*', mb_strlen((string) $value)); + } + + if (is_array($value)) { + return array_map(fn ($v) => $this->maskValue($v), $value); + } + + return $value; + } + + private function maybeRedactImage(mixed $value): mixed + { + if (is_string($value) && str_starts_with($value, 'data:image/')) { + return '[image]'; + } + + return $value; + } +} diff --git a/src/Middleware/TreblleMiddleware.php b/src/Middleware/TreblleMiddleware.php new file mode 100644 index 0000000..a009aa8 --- /dev/null +++ b/src/Middleware/TreblleMiddleware.php @@ -0,0 +1,376 @@ +masker = new SensitiveDataMasker($config->maskedKeywords); + $this->pathMatcher = new PathMatcher($config->excludedPaths); + $this->requestTypeFilter = new RequestTypeFilter(); + $this->transport = new AsyncTransport( + client: new IngressClient(), + debug: $config->debug, + ); + $this->serverCollector = new ServerCollector(); + $this->languageCollector = new LanguageCollector(); + } + + /** + * Convenience factory — mirrors Treblle::create() signature. + * + * @param array{ + * debug?: bool, + * masked_keywords?: string[], + * excluded_paths?: string[], + * custom_ingress?: string, + * enabled?: bool, + * } $options + */ + public static function create(string $sdkToken, string $apiKey, array $options = []): self + { + return new self(new TreblleConfig( + sdkToken: $sdkToken, + apiKey: $apiKey, + debug: (bool) ($options['debug'] ?? false), + maskedKeywords: array_values($options['masked_keywords'] ?? TreblleConfig::DEFAULT_MASKED_KEYWORDS), + excludedPaths: array_values($options['excluded_paths'] ?? []), + customIngress: $options['custom_ingress'] ?? null, + enabled: (bool) ($options['enabled'] ?? true), + )); + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + if (! $this->config->enabled || $this->config->sdkToken === '' || $this->config->apiKey === '') { + return $handler->handle($request); + } + + $path = $request->getUri()->getPath(); + + if ($this->pathMatcher->matches($path)) { + if ($this->config->debug) { + error_log("[TREBLLE] Request excluded by path: {$path}"); + } + + return $handler->handle($request); + } + + $startTime = microtime(true); + $errorCollector = new ErrorCollector(); + + try { + $response = $handler->handle($request); + } catch (\Throwable $e) { + $errorCollector->addError('onException', 'UNHANDLED_EXCEPTION', $e->getMessage(), $e->getFile(), $e->getLine()); + + throw $e; + } + + $uri = (string) $request->getUri(); + $contentType = $response->getHeaderLine('Content-Type'); + + if ($this->requestTypeFilter->shouldSkip($uri, $contentType)) { + if ($this->config->debug) { + error_log("[TREBLLE] Skipping non-REST request: {$uri}"); + } + + return $response; + } + + $payload = $this->buildPayload($request, $response, $errorCollector, $startTime); + + if ($payload !== false) { + $endpoint = $this->config->getIngressUrl(); + $sdkToken = $this->config->sdkToken; + $transport = $this->transport; + $debug = $this->config->debug; + + register_shutdown_function(static function () use ($payload, $endpoint, $sdkToken, $transport, $debug): void { + try { + $transport->send($payload, $endpoint, $sdkToken); + } catch (\Throwable $e) { + if ($debug) { + error_log('[TREBLLE] Failed to send payload: ' . $e->getMessage()); + } + } + }); + } + + return $response; + } + + private function buildPayload( + ServerRequestInterface $request, + ResponseInterface $response, + ErrorCollector $errors, + float $startTime, + ): string|false { + $requestData = $this->extractRequestData($request); + $responseData = $this->extractResponseData($response, $startTime); + $metadata = Treblle::getMetadata(); + + $data = [ + 'server' => $this->serverCollector->collect(), + 'language' => $this->languageCollector->collect(), + 'request' => [ + 'timestamp' => $requestData['timestamp'], + 'ip' => $requestData['ip'], + 'url' => $requestData['url'], + 'user_agent' => $requestData['user_agent'], + 'method' => $requestData['method'], + 'headers' => $this->masker->maskHeaders($requestData['headers']), + 'body' => $this->masker->maskData($requestData['body']), + 'route_path' => $requestData['route_path'], + 'query' => $this->masker->maskData($requestData['query']), + ], + 'response' => [ + 'headers' => $this->masker->maskHeaders($responseData['headers']), + 'code' => $responseData['code'], + 'size' => $responseData['size'], + 'load_time' => $responseData['load_time'], + 'body' => $this->masker->maskData($responseData['body']), + ], + 'errors' => $errors->getErrors(), + ]; + + if ($metadata !== []) { + $data['metadata'] = $metadata; + } + + $payload = [ + 'api_key' => $this->config->sdkToken, + 'project_id' => $this->config->apiKey, + 'sdk' => TreblleConfig::SDK_NAME, + 'version' => TreblleConfig::SDK_VERSION, + 'data' => $data, + ]; + + try { + $json = json_encode( + $payload, + JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION + ); + } catch (\JsonException) { + return false; + } + + unset($payload); + + $gzipped = gzencode($json, 1); + + return $gzipped === false ? false : $gzipped; + } + + /** + * @return array{ + * timestamp: string, + * ip: string, + * url: string, + * user_agent: string, + * method: string, + * headers: array, + * body: array, + * route_path: string|null, + * query: array + * } + */ + private function extractRequestData(ServerRequestInterface $request): array + { + $headers = []; + foreach ($request->getHeaders() as $name => $values) { + $headers[$name] = implode(', ', $values); + } + + // Route path: check PSR-7 attribute first, then fall back to Treblle static state + $routePath = $request->getAttribute('_treblle_route_path'); + if (! is_string($routePath) || $routePath === '') { + $routePath = RequestCollector::getRoutePath(); + } + + return [ + 'timestamp' => gmdate('Y-m-d H:i:s'), + 'ip' => $this->resolveClientIp($request), + 'url' => (string) $request->getUri(), + 'user_agent' => $request->getHeaderLine('User-Agent'), + 'method' => strtoupper($request->getMethod()), + 'headers' => $headers, + 'body' => $this->parseRequestBody($request), + 'route_path' => is_string($routePath) && $routePath !== '' ? $routePath : null, + 'query' => $request->getQueryParams(), + ]; + } + + /** + * @return array{ + * headers: array, + * code: int, + * size: int, + * load_time: float, + * body: array + * } + */ + private function extractResponseData(ResponseInterface $response, float $startTime): array + { + $body = $response->getBody(); + $rawBody = (string) $body; + + if ($body->isSeekable()) { + $body->rewind(); + } + + $size = mb_strlen($rawBody, '8bit'); + + $headers = []; + foreach ($response->getHeaders() as $name => $values) { + $headers[$name] = implode(', ', $values); + } + + return [ + 'headers' => $headers, + 'code' => $response->getStatusCode(), + 'size' => $size, + 'load_time' => round((microtime(true) - $startTime) * 1000, 2), + 'body' => $this->parseResponseBody($rawBody, $size), + ]; + } + + /** @return array */ + private function parseRequestBody(ServerRequestInterface $request): array + { + $contentType = strtolower($request->getHeaderLine('Content-Type')); + + // File uploads — capture metadata only + $uploadedFiles = $request->getUploadedFiles(); + if (! empty($uploadedFiles)) { + return $this->extractUploadedFilesMeta($uploadedFiles); + } + + // Form data — framework has already parsed this + if (str_contains($contentType, 'application/x-www-form-urlencoded') || str_contains($contentType, 'multipart/form-data')) { + $parsed = $request->getParsedBody(); + + return is_array($parsed) ? $parsed : []; + } + + $body = $request->getBody(); + $rawBody = (string) $body; + + if ($body->isSeekable()) { + $body->rewind(); + } + + $size = mb_strlen($rawBody, '8bit'); + + if ($size > 2 * 1024 * 1024) { + return ['error' => 'Payload exceeds 2MB', 'size' => $size]; + } + + if ($rawBody === '') { + return []; + } + + $data = json_decode($rawBody, true); + + return is_array($data) ? $data : ['error' => 'Request payload is not valid JSON']; + } + + /** @return array */ + private function parseResponseBody(string $rawBody, int $size): array + { + if ($rawBody === '') { + return []; + } + + if ($size > 2 * 1024 * 1024) { + return ['error' => 'Payload exceeds 2MB', 'size' => $size]; + } + + $data = json_decode($rawBody, true); + + if (! is_array($data)) { + return ['error' => 'Response payload is not valid JSON']; + } + + $result = []; + foreach ($data as $key => $value) { + $result[(string) $key] = $value; + } + + return $result; + } + + private function resolveClientIp(ServerRequestInterface $request): string + { + $serverParams = $request->getServerParams(); + + $clientIp = $serverParams['HTTP_CLIENT_IP'] ?? null; + if (is_string($clientIp) && $clientIp !== '') { + return trim(explode(',', $clientIp)[0]); + } + + $forwardedFor = $serverParams['HTTP_X_FORWARDED_FOR'] ?? null; + if (is_string($forwardedFor) && $forwardedFor !== '') { + return trim(explode(',', $forwardedFor)[0]); + } + + $remoteAddr = $serverParams['REMOTE_ADDR'] ?? null; + if (is_string($remoteAddr) && $remoteAddr !== '') { + return $remoteAddr; + } + + return 'bogon'; + } + + /** + * @param array> $files + * @return array + */ + private function extractUploadedFilesMeta(array $files): array + { + $uploads = []; + + foreach ($files as $file) { + if ($file instanceof UploadedFileInterface) { + $uploads[] = [ + 'name' => $file->getClientFilename(), + 'type' => $file->getClientMediaType(), + 'size' => $file->getSize(), + ]; + } + } + + if ($uploads === []) { + return []; + } + + return ['_treblle_file_upload' => count($uploads) === 1 ? $uploads[0] : $uploads]; + } +} diff --git a/src/Payload/PayloadBuilder.php b/src/Payload/PayloadBuilder.php new file mode 100644 index 0000000..5ac32c4 --- /dev/null +++ b/src/Payload/PayloadBuilder.php @@ -0,0 +1,90 @@ + $metadata + */ + public function build(array $metadata = []): string|false + { + $request = $this->request->collect(); + $response = $this->response->collect(); + + $data = [ + 'server' => $this->server->collect(), + 'language' => $this->language->collect(), + 'request' => [ + 'timestamp' => $request['timestamp'], + 'ip' => $request['ip'], + 'url' => $request['url'], + 'user_agent' => $request['user_agent'], + 'method' => $request['method'], + 'headers' => $this->masker->maskHeaders($request['headers']), + 'body' => $this->masker->maskData($request['body']), + 'route_path' => $request['route_path'], + 'query' => $this->masker->maskData($request['query']), + ], + 'response' => [ + 'headers' => $this->masker->maskHeaders($response['headers']), + 'code' => $response['code'], + 'size' => $response['size'], + 'load_time' => $response['load_time'], + 'body' => $this->masker->maskData($response['body']), + ], + 'errors' => $this->errors->getErrors(), + ]; + + if ($metadata !== []) { + $data['metadata'] = $metadata; + } + + $payload = [ + 'api_key' => $this->config->sdkToken, + 'project_id' => $this->config->apiKey, + 'sdk' => TreblleConfig::SDK_NAME, + 'version' => TreblleConfig::SDK_VERSION, + 'data' => $data, + ]; + + try { + $json = json_encode( + $payload, + JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION + ); + } catch (\JsonException) { + return false; + } + + unset($payload); + + $gzipped = gzencode($json, 1); + + return $gzipped === false ? false : $gzipped; + } +} diff --git a/src/Transport/AsyncTransport.php b/src/Transport/AsyncTransport.php new file mode 100644 index 0000000..06289bd --- /dev/null +++ b/src/Transport/AsyncTransport.php @@ -0,0 +1,75 @@ +debug) { + error_log('[TREBLLE] Async: using fastcgi_finish_request'); + } + + if (ob_get_level() > 0) { + ob_end_flush(); + } + + fastcgi_finish_request(); + + $this->client->send($gzippedPayload, $endpoint, $sdkToken, $this->debug); + + return; + } + + // Strategy 2: pcntl_fork (Unix/Linux only) + // Fork a child process to send data; parent returns immediately. + if (function_exists('pcntl_fork')) { + if ($this->debug) { + error_log('[TREBLLE] Async: using pcntl_fork'); + } + + if (ob_get_level() > 0) { + ob_end_flush(); + } + + $pid = pcntl_fork(); + + if ($pid === -1) { + // Fork failed — fall through to sync + } elseif ($pid === 0) { + // Child process: send and exit + $this->client->send($gzippedPayload, $endpoint, $sdkToken, $this->debug); + exit(0); + } else { + // Parent: reap child to avoid zombies + pcntl_waitpid($pid, $status, WNOHANG); + + return; + } + } + + // Strategy 3: Sync fallback + if ($this->debug) { + error_log('[TREBLLE] Async: using sync fallback'); + } + + if (ob_get_level() > 0) { + ob_end_flush(); + } + + $this->client->send($gzippedPayload, $endpoint, $sdkToken, $this->debug); + } +} diff --git a/src/Treblle.php b/src/Treblle.php index 590e94e..b4bf0f3 100644 --- a/src/Treblle.php +++ b/src/Treblle.php @@ -4,465 +4,265 @@ namespace Treblle\Php; -use Throwable; -use GuzzleHttp\ClientInterface; -use Treblle\Php\DataTransferObject\Data; -use GuzzleHttp\Exception\GuzzleException; -use Treblle\Php\DataTransferObject\Error; -use Treblle\Php\Contract\ErrorDataProvider; -use Treblle\Php\Contract\ServerDataProvider; -use Treblle\Php\Helpers\ErrorTypeTranslator; -use Treblle\Php\Contract\RequestDataProvider; -use Treblle\Php\Contract\LanguageDataProvider; -use Treblle\Php\Contract\ResponseDataProvider; - -/** - * Core Treblle SDK class for capturing and transmitting API data. - * - * This is the main class of the Treblle PHP SDK. It captures HTTP request/response - * data, PHP errors, and exceptions, then transmits them to the Treblle platform - * for API monitoring, analytics, and documentation. - * - * Key responsibilities: - * - Registers PHP error, exception, and shutdown handlers - * - Captures errors and exceptions during request processing - * - Collects server, language, request, response, and error data - * - Builds JSON payload with masked sensitive data - * - Transmits data to Treblle endpoints - * - Supports optional background processing via pcntl_fork - * - * Usage: - * Typically created via TreblleFactory rather than direct instantiation. - * The factory registers handlers automatically on creation. - * - * Data Flow: - * 1. Handlers registered: set_error_handler, set_exception_handler, register_shutdown_function - * 2. During request: Errors/exceptions captured via onError/onException - * 3. On shutdown: onShutdown builds payload from all providers and sends to Treblle - * - * Background Processing: - * When fork_process is enabled and pcntl extension is available: - * - Forks a child process to send data - * - Parent process continues/exits immediately - * - Child process sends data then terminates - * - * Debug Mode: - * - OFF (default): Silently catches and ignores SDK errors - * - ON: Throws exceptions for easier troubleshooting - * - * Get started at https://platform.treblle.com - * - * @package Treblle\Php - */ -final class Treblle +use Treblle\Php\Config\TreblleConfig; +use Treblle\Php\DataCollector\ErrorCollector; +use Treblle\Php\DataCollector\LanguageCollector; +use Treblle\Php\DataCollector\RequestCollector; +use Treblle\Php\DataCollector\ResponseCollector; +use Treblle\Php\DataCollector\ServerCollector; +use Treblle\Php\Filter\PathMatcher; +use Treblle\Php\Filter\RequestTypeFilter; +use Treblle\Php\Http\IngressClient; +use Treblle\Php\Masking\SensitiveDataMasker; +use Treblle\Php\Payload\PayloadBuilder; +use Treblle\Php\Transport\AsyncTransport; + +class Treblle { - /** - * @var string SDK name identifier (default: 'php') - */ - private string $name = 'php'; + private static bool $initialized = false; - /** - * @var float SDK version number (default: 5.0) - */ - private float $version = 5.0; + /** @var array */ + private static array $metadata = []; - /** - * Constructs a new Treblle SDK instance. - * - * This constructor is typically not called directly. Use TreblleFactory::create() - * instead, which provides sensible defaults and automatic handler registration. - * - * @param string $apiKey Your Treblle project API key - * @param string $sdkToken Your Treblle SDK authentication token - * @param ClientInterface $client HTTP client for transmitting data to Treblle - * @param ServerDataProvider $serverDataProvider Provides server/OS information - * @param LanguageDataProvider $languageDataProvider Provides PHP version info - * @param RequestDataProvider $requestDataProvider Provides HTTP request data - * @param ResponseDataProvider $responseDataProvider Provides HTTP response data - * @param ErrorDataProvider $errorDataProvider Stores and provides error data - * @param bool $debug Enable debug mode (throws exceptions instead of silent failures) - * @param string|null $url Custom Treblle endpoint URL (uses random default if null) - * @param bool $forkProcess Enable background processing via pcntl_fork - */ - public function __construct( - private readonly string $apiKey, - private readonly string $sdkToken, - private readonly ClientInterface $client, - private readonly ServerDataProvider $serverDataProvider, - private readonly LanguageDataProvider $languageDataProvider, - private readonly RequestDataProvider $requestDataProvider, - private readonly ResponseDataProvider $responseDataProvider, - private readonly ErrorDataProvider $errorDataProvider, - private readonly bool $debug, - private readonly ?string $url = null, - private readonly bool $forkProcess = false - ) { - } + private readonly ErrorCollector $errorCollector; + private readonly RequestCollector $requestCollector; + private readonly ResponseCollector $responseCollector; + private readonly PayloadBuilder $payloadBuilder; + private readonly AsyncTransport $transport; + private readonly PathMatcher $pathMatcher; + private readonly RequestTypeFilter $requestTypeFilter; - /** - * Captures PHP errors via set_error_handler callback. - * - * This method is registered as the global error handler using set_error_handler() - * in TreblleFactory. It captures all PHP errors (warnings, notices, fatal errors, etc.) - * and stores them in the error data provider for transmission to Treblle. - * - * The error type is translated from its integer constant (e.g., E_WARNING) - * to a human-readable string (e.g., 'E_WARNING') using ErrorTypeTranslator. - * - * Error Handling: - * - Debug OFF: Silently catches any exceptions during error capture - * - Debug ON: Re-throws exceptions for troubleshooting - * - * @param int $type The error type constant (E_ERROR, E_WARNING, etc.) - * @param string $message The error message - * @param string $file The filename where the error occurred - * @param int $line The line number where the error occurred - * @return bool Always returns false to allow normal error handling to continue - * @throws Throwable Only in debug mode if an exception occurs during error capture - */ - public function onError(int $type, string $message, string $file, int $line): bool + private function __construct(private readonly TreblleConfig $config) { - try { - $this->errorDataProvider->addError(new Error( - $message, - $file, - $line, - 'onError', - ErrorTypeTranslator::translateErrorType($type), - )); - } catch (Throwable $throwable) { - if ($this->debug) { - throw $throwable; - } + $masker = new SensitiveDataMasker($config->maskedKeywords); + + $this->errorCollector = new ErrorCollector(); + $this->requestCollector = new RequestCollector(); + $this->responseCollector = new ResponseCollector(); + $this->pathMatcher = new PathMatcher($config->excludedPaths); + $this->requestTypeFilter = new RequestTypeFilter(); + + $this->payloadBuilder = new PayloadBuilder( + config: $config, + masker: $masker, + server: new ServerCollector(), + language: new LanguageCollector(), + request: $this->requestCollector, + response: $this->responseCollector, + errors: $this->errorCollector, + ); + + $this->transport = new AsyncTransport( + client: new IngressClient(), + debug: $config->debug, + ); + + if (! $config->enabled) { + return; } - return false; + // Buffer output so we can capture the response body + ob_start(); + + $this->registerHandlers(); } /** - * Captures unhandled PHP exceptions via set_exception_handler callback. - * - * This method is registered as the global exception handler using set_exception_handler() - * in TreblleFactory. It captures all unhandled exceptions that would otherwise - * terminate the script and stores them for transmission to Treblle. - * - * Exception details captured: - * - Message from $exception->getMessage() - * - File path from $exception->getFile() - * - Line number from $exception->getLine() - * - Source marked as 'onException' - * - Type marked as 'UNHANDLED_EXCEPTION' - * - * Error Handling: - * - Debug OFF: Silently catches any exceptions during exception capture - * - Debug ON: Re-throws exceptions for troubleshooting - * - * @param Throwable $exception The unhandled exception to capture - * @return void - * @throws Throwable Only in debug mode if an exception occurs during exception capture + * Initialize Treblle with a pre-built config object. */ - public function onException(Throwable $exception): void + public static function start(TreblleConfig $config): void { - try { - $this->errorDataProvider->addError(new Error( - $exception->getMessage(), - $exception->getFile(), - $exception->getLine(), - 'onException', - 'UNHANDLED_EXCEPTION', - )); - } catch (Throwable $throwable) { - if ($this->debug) { - throw $throwable; - } + if (self::$initialized) { + return; } + + self::$initialized = true; + + new self($config); } /** - * Collects and transmits all captured data when PHP finishes processing. - * - * This method is registered as a shutdown handler using register_shutdown_function() - * in TreblleFactory. It runs at the end of the request lifecycle and: - * 1. Builds the complete payload from all data providers - * 2. Encodes the payload as JSON - * 3. Transmits the data to Treblle - * - * Transmission Strategy: - * - If pcntl_fork is unavailable or disabled: Sends data in main process (blocking) - * - If pcntl_fork is available and enabled: - * - Forks a child process to send data (non-blocking) - * - Parent process exits immediately - * - Child process sends data then terminates itself - * - * Error Handling: - * - Any failure during payload building or encoding aborts transmission silently - * - Debug OFF: Returns without sending anything on any failure - * - Debug ON: Re-throws exceptions for troubleshooting - * - * Background Processing Flow: - * 1. Attempt to fork process using pcntl_fork() - * 2. If fork fails (returns -1): Fall back to blocking transmission - * 3. If fork succeeds (returns 0 in child): Child sends data and kills itself - * 4. Parent process (PID > 0): Continues/exits without waiting - * - * @return void - * @throws Throwable Only in debug mode if an exception occurs during shutdown + * Convenience factory — builds TreblleConfig from raw values. + * + * @param array{ + * debug?: bool, + * masked_keywords?: string[], + * excluded_paths?: string[], + * custom_ingress?: string, + * enabled?: bool, + * } $options */ - public function onShutdown(): void + public static function create(string $sdkToken, string $apiKey, array $options = []): void { - try { - $payload = $this->buildPayload(); - $payload = json_encode($payload); + self::start(new TreblleConfig( + sdkToken: $sdkToken, + apiKey: $apiKey, + debug: (bool) ($options['debug'] ?? false), + maskedKeywords: array_values($options['masked_keywords'] ?? TreblleConfig::DEFAULT_MASKED_KEYWORDS), + excludedPaths: array_values($options['excluded_paths'] ?? []), + customIngress: $options['custom_ingress'] ?? null, + enabled: (bool) ($options['enabled'] ?? true), + )); + } + + private function registerHandlers(): void + { + $collector = $this->errorCollector; + $debug = $this->config->debug; + + set_error_handler( + static function (int $errno, string $errstr, string $errfile, int $errline) use ($collector): bool { + $collector->addError('onError', ErrorTypeTranslator::translate($errno), $errstr, $errfile, $errline); - if (false === $payload || '[]' === $payload || mb_strlen($payload) > 2_000_000) { - return; + // Return false to allow PHP's internal error handler to run as well + return false; + } + ); + + set_exception_handler( + static function (\Throwable $e) use ($collector, $debug): void { + $collector->addError( + 'onException', + 'UNHANDLED_EXCEPTION', + $e->getMessage(), + $e->getFile(), + $e->getLine() + ); + + if ($debug) { + error_log('[TREBLLE] Unhandled exception captured: ' . $e->getMessage()); + } } - } catch (Throwable $throwable) { - if ($this->debug) { - throw $throwable; + ); + + register_shutdown_function(function (): void { + $this->handleShutdown(); + }); + } + + private function handleShutdown(): void + { + // Capture fatal errors that PHP itself handles at shutdown + $lastError = error_get_last(); + if ($lastError !== null && in_array($lastError['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR], true)) { + $this->errorCollector->addError( + 'onShutdown', + ErrorTypeTranslator::translate($lastError['type']), + $lastError['message'], + $lastError['file'], + $lastError['line'] + ); + } + + if (! $this->config->enabled) { + if ($this->config->debug) { + error_log('[TREBLLE] Treblle is disabled'); } + $this->flushOutput(); return; } - if (! function_exists('pcntl_fork') || false === $this->forkProcess) { - $this->collectData($payload); + if ($this->config->sdkToken === '' || $this->config->apiKey === '') { + $this->flushOutput(); return; } - $pid = pcntl_fork(); + $serverUri = $_SERVER['REQUEST_URI'] ?? '/'; + $uri = is_string($serverUri) ? $serverUri : '/'; + $parsed = parse_url($uri, PHP_URL_PATH); + $path = is_string($parsed) ? $parsed : $uri; - if ($this->isUnableToForkProcess($pid)) { - $this->collectData($payload); + if ($this->pathMatcher->matches($path)) { + if ($this->config->debug) { + error_log("[TREBLLE] Request excluded by path: {$path}"); + } + $this->flushOutput(); return; } - if ($this->isChildProcess($pid)) { - $this->collectData($payload); - $this->killProcessWithId((int) getmypid()); - } - } + // Collect response data then immediately flush — response body is captured above, + // freeing the ob buffer before the heavier payload build step below. + $response = $this->responseCollector->collect(); + $contentType = $response['content_type']; + $this->flushOutput(); - /** - * Gets the Treblle API endpoint URL for data transmission. - * - * Returns either the custom URL provided during construction, or randomly - * selects one of three default Treblle endpoints for load balancing: - * - https://rocknrolla.treblle.com - * - https://punisher.treblle.com - * - https://sicario.treblle.com - * - * Random selection distributes load across Treblle's infrastructure. - * - * @return string The base URL for Treblle API requests - */ - public function getBaseUrl(): string - { - $urls = [ - 'https://rocknrolla.treblle.com', - 'https://punisher.treblle.com', - 'https://sicario.treblle.com', - ]; + if ($this->requestTypeFilter->shouldSkip($uri, $contentType)) { + if ($this->config->debug) { + error_log("[TREBLLE] Skipping non-REST request: {$uri}"); + } - return $this->url ?? $urls[array_rand($urls)]; - } + return; + } - /** - * Sets the SDK version number sent to Treblle. - * - * This is typically used by framework-specific integrations (Laravel, Symfony, etc.) - * to identify which integration is being used. The default is 5.0 for vanilla PHP. - * - * @param float $version The version number to set - * @return self Fluent interface for method chaining - */ - public function setVersion(float $version): self - { - $this->version = $version; + $payload = $this->payloadBuilder->build(self::$metadata); - return $this; - } + if ($payload === false) { + return; + } - /** - * Sets the SDK name identifier sent to Treblle. - * - * This is typically used by framework-specific integrations to identify - * the platform. For example: - * - 'php' for vanilla PHP (default) - * - 'laravel' for Laravel integration - * - 'symfony' for Symfony integration - * - * @param string $name The SDK name to set - * @return self Fluent interface for method chaining - */ - public function setName(string $name): self - { - $this->name = $name; + $endpoint = $this->config->getIngressUrl(); - return $this; + try { + $this->transport->send($payload, $endpoint, $this->config->sdkToken); + } catch (\Throwable $e) { + if ($this->config->debug) { + error_log('[TREBLLE] Failed to send payload: ' . $e->getMessage()); + } + } } - /** - * Builds the complete payload for transmission to Treblle. - * - * Collects data from all registered providers and assembles it into - * a structured array containing: - * - api_key: Project API key for authentication - * - sdk_token: SDK token for authorization - * - sdk: SDK name identifier (e.g., 'php', 'laravel') - * - version: SDK version number - * - data: Complete request/response/error data from all providers - * - * Data Collection: - * - Server info (OS, protocol, timezone) from ServerDataProvider - * - Language info (PHP version) from LanguageDataProvider - * - Request data (headers, body, method) from RequestDataProvider - * - Response data (status, body, headers) from ResponseDataProvider - * - Error data (exceptions, warnings) from ErrorDataProvider - * - * All sensitive fields are masked before inclusion in the payload. - * - * Any exception thrown by a data provider propagates to the caller (onShutdown), - * which handles it according to the debug mode setting. - * - * @return array The complete payload array - * @throws Throwable If any data provider throws during data collection - */ - private function buildPayload(): array + private function flushOutput(): void { - return [ - 'api_key' => $this->apiKey, - 'sdk_token' => $this->sdkToken, - 'sdk' => $this->name, - 'version' => $this->version, - 'data' => new Data( - $this->serverDataProvider->getServer(), - $this->languageDataProvider->getLanguage(), - $this->requestDataProvider->getRequest(), - $this->responseDataProvider->getResponse(), - $this->errorDataProvider->getErrors() - ), - ]; + if (ob_get_level() > 0) { + ob_end_flush(); + } } /** - * Transmits the JSON payload to Treblle's API endpoints. - * - * Sends an HTTP POST request to Treblle with the JSON-encoded payload. - * Uses Guzzle HTTP client with the following configuration: - * - Method: POST - * - Endpoint: Random Treblle URL from getBaseUrl() - * - Connection timeout: 3 seconds - * - Request timeout: 3 seconds - * - SSL verification: Disabled - * - HTTP errors: Suppressed (doesn't throw on 4xx/5xx) - * - Headers: - * - Content-Type: application/json - * - x-api-key: SDK token for authentication - * - * Short timeouts ensure minimal impact on application performance. - * Errors during transmission are silently ignored unless debug mode is enabled. + * Attach custom key/value pairs to the current request payload under `data.metadata`. + * Call this anywhere during the request lifecycle — the data is read at shutdown. + * Multiple calls are merged together. * - * Payload Compression: - * The payload is gzip-compressed before transmission to reduce bandwidth usage. - * If compression fails, the original uncompressed payload is sent as a fallback. - * - * Error Handling: - * - Debug OFF: Silently catches and ignores transmission failures - * - Debug ON: Re-throws exceptions for troubleshooting - * - * @param string $payload The JSON-encoded payload to transmit - * @return void - * @throws Throwable Only in debug mode if transmission fails - * @throws GuzzleException Only in debug mode if HTTP request fails + * @param array $metadata */ - private function collectData(string $payload): void + public static function metadata(array $metadata): void { - try { - $compressed = function_exists('gzencode') ? gzencode($payload) : false; - - $headers = [ - 'Content-Type' => 'application/json', - 'x-api-key' => $this->sdkToken, - ]; - - if (false !== $compressed) { - $body = $compressed; - $headers['Content-Encoding'] = 'gzip'; - } else { - $body = $payload; - } - - if (null !== $this->url) { - $headers['Connection'] = 'keep-alive'; - } - - $this->client->request( - 'POST', - $this->getBaseUrl(), - [ - 'connect_timeout' => 3, - 'timeout' => 3, - 'verify' => false, - 'http_errors' => false, - 'headers' => $headers, - 'body' => $body, - ] - ); - } catch (Throwable $throwable) { - if ($this->debug) { - throw $throwable; - } - } + self::$metadata = array_merge(self::$metadata, $metadata); } - /** - * Checks if the current process is the child process after forking. - * - * After calling pcntl_fork(): - * - Returns 0 in the child process - * - Returns positive PID in the parent process - * - Returns -1 if fork failed - * - * @param int $pid The process ID returned by pcntl_fork() - * @return bool True if this is the child process (PID === 0) - */ - private function isChildProcess(int $pid): bool + /** @return array */ + public static function getMetadata(): array { - return 0 === $pid; + return self::$metadata; } /** - * Checks if process forking failed. + * Set the parameterised route path for the current request (e.g. `articles/{id}`). + * + * Plain PHP has no built-in router, so the SDK cannot infer the route pattern + * automatically. Call this after your router has resolved the route: * - * pcntl_fork() returns -1 when unable to fork a new process, - * typically due to system resource limitations. + * Treblle::setRoutePath('articles/{id}'); * - * @param int $pid The process ID returned by pcntl_fork() - * @return bool True if fork failed (PID === -1) + * Takes priority over the $_SERVER['TREBLLE_ROUTE_PATH'] fallback. */ - private function isUnableToForkProcess(int $pid): bool + public static function setRoutePath(string $path): void { - return -1 === $pid; + RequestCollector::setRoutePath($path); } /** - * Terminates a process by its process ID. - * - * Uses platform-specific commands to kill the process: - * - Windows: taskkill /F /T /PID {pid} - * - Unix/Linux/macOS: kill -9 {pid} - * - * This is used to terminate the child process after it finishes - * transmitting data to Treblle in background mode. - * - * @param int $pid The process ID to terminate - * @return void + * Reset the initialized flag so Treblle can be re-initialized on the next request. + * Call this at the start of each request cycle in persistent runtimes + * (Swoole, RoadRunner, FrankenPHP, ReactPHP). */ - private function killProcessWithId(int $pid): void + public static function reset(): void { - 'WIN' === mb_strtoupper(mb_substr(PHP_OS, 0, 3)) ? exec("taskkill /F /T /PID {$pid}") : exec("kill -9 {$pid}"); + self::$initialized = false; + self::$metadata = []; + RequestCollector::reset(); } } diff --git a/tests/Unit/CircuitBreaker/CircuitBreakerTest.php b/tests/Unit/CircuitBreaker/CircuitBreakerTest.php new file mode 100644 index 0000000..730ca1f --- /dev/null +++ b/tests/Unit/CircuitBreaker/CircuitBreakerTest.php @@ -0,0 +1,196 @@ +storage = new InMemoryStorage(); + $this->cb = new CircuitBreaker($this->storage, 'https://ingress.treblle.com'); + } + + public function testClosedByDefault(): void + { + self::assertFalse($this->cb->isOpen()); + } + + public function testOpensAfterFailureThreshold(): void + { + for ($i = 0; $i < 5; $i++) { + $this->cb->recordFailure(500); + } + + self::assertTrue($this->cb->isOpen()); + } + + public function testDoesNotOpenBelowThreshold(): void + { + for ($i = 0; $i < 4; $i++) { + $this->cb->recordFailure(500); + } + + self::assertFalse($this->cb->isOpen()); + } + + public function testOpensImmediatelyOn429(): void + { + $this->cb->recordFailure(429, 120); + + self::assertTrue($this->cb->isOpen()); + } + + public function testOpensImmediatelyOn429WithoutRetryAfter(): void + { + $this->cb->recordFailure(429); + + self::assertTrue($this->cb->isOpen()); + } + + public function test4xxNonRateLimitDoesNotOpen(): void + { + for ($i = 0; $i < 10; $i++) { + $this->cb->recordFailure(403); + } + + self::assertFalse($this->cb->isOpen()); + } + + public function testNetworkErrorCountsAsFailure(): void + { + for ($i = 0; $i < 5; $i++) { + $this->cb->recordFailure(0); + } + + self::assertTrue($this->cb->isOpen()); + } + + public function testSuccessClosesCircuit(): void + { + for ($i = 0; $i < 5; $i++) { + $this->cb->recordFailure(500); + } + + self::assertTrue($this->cb->isOpen()); + + $this->cb->recordSuccess(); + + self::assertFalse($this->cb->isOpen()); + } + + public function testCircuitAllowsProbeAfterBackoffExpires(): void + { + for ($i = 0; $i < 5; $i++) { + $this->cb->recordFailure(500); + } + + self::assertTrue($this->cb->isOpen()); + + // Simulate the stored backoff timestamp being in the past. + $this->storage->expireUntilTimestamp(); + + // Circuit should allow one probe through. + self::assertFalse($this->cb->isOpen()); + } + + public function testOnlyOneWorkerGetsHalfOpenProbeSlot(): void + { + for ($i = 0; $i < 5; $i++) { + $this->cb->recordFailure(500); + } + + $this->storage->expireUntilTimestamp(); + + // First caller wins the probe slot → allowed through (isOpen = false). + self::assertFalse($this->cb->isOpen()); + + // Second caller is blocked while probe is in-flight (isOpen = true). + self::assertTrue($this->cb->isOpen()); + } +} + +/** + * In-memory storage for testing — avoids APCu / file I/O. + * + * @internal + */ +class InMemoryStorage implements StorageInterface +{ + /** @var array */ + private array $store = []; + + public function increment(string $key): int + { + $current = $this->store[$key]['val'] ?? 0; + $this->store[$key] = ['val' => $current + 1, 'exp' => null]; + + return $current + 1; + } + + public function set(string $key, int $value, int $ttl): void + { + $this->store[$key] = ['val' => $value, 'exp' => time() + $ttl]; + } + + public function setIfAbsent(string $key, int $value, int $ttl): bool + { + if (isset($this->store[$key])) { + $entry = $this->store[$key]; + if ($entry['exp'] === null || $entry['exp'] > time()) { + return false; + } + } + + $this->store[$key] = ['val' => $value, 'exp' => time() + $ttl]; + + return true; + } + + public function get(string $key): ?int + { + if (! isset($this->store[$key])) { + return null; + } + + $entry = $this->store[$key]; + + if ($entry['exp'] !== null && $entry['exp'] <= time()) { + unset($this->store[$key]); + + return null; + } + + return $entry['val']; + } + + public function delete(string ...$keys): void + { + foreach ($keys as $key) { + unset($this->store[$key]); + } + } + + /** + * Simulate the backoff period having elapsed: set all 'until' key values + * to a past timestamp so isOpen() reaches the half-open probe logic. + * The key itself remains present (long TTL) — only its VALUE becomes past. + */ + public function expireUntilTimestamp(): void + { + foreach (array_keys($this->store) as $key) { + if (str_contains($key, '_until')) { + $this->store[$key]['val'] = time() - 1; + } + } + } +} diff --git a/tests/Unit/Config/TreblleConfigTest.php b/tests/Unit/Config/TreblleConfigTest.php new file mode 100644 index 0000000..5fc6e7a --- /dev/null +++ b/tests/Unit/Config/TreblleConfigTest.php @@ -0,0 +1,66 @@ +sdkToken); + self::assertSame('api-key', $config->apiKey); + self::assertFalse($config->debug); + self::assertSame(TreblleConfig::DEFAULT_MASKED_KEYWORDS, $config->maskedKeywords); + self::assertSame([], $config->excludedPaths); + self::assertNull($config->customIngress); + self::assertTrue($config->enabled); + } + + public function testDefaultIngressUrl(): void + { + $config = new TreblleConfig('sdk-token', 'api-key'); + + self::assertSame('https://ingress.treblle.com', $config->getIngressUrl()); + } + + public function testCustomIngressUrl(): void + { + $config = new TreblleConfig('sdk-token', 'api-key', customIngress: 'https://ingress-eu.treblle.com'); + + self::assertSame('https://ingress-eu.treblle.com', $config->getIngressUrl()); + } + + public function testCustomMaskedKeywords(): void + { + $keywords = ['token', 'secret_key']; + $config = new TreblleConfig('sdk-token', 'api-key', maskedKeywords: $keywords); + + self::assertSame($keywords, $config->maskedKeywords); + } + + public function testDefaultMaskedKeywordsContainsPassword(): void + { + self::assertContains('password', TreblleConfig::DEFAULT_MASKED_KEYWORDS); + self::assertContains('ssn', TreblleConfig::DEFAULT_MASKED_KEYWORDS); + self::assertContains('credit_score', TreblleConfig::DEFAULT_MASKED_KEYWORDS); + } + + public function testSdkConstants(): void + { + self::assertSame('php', TreblleConfig::SDK_NAME); + self::assertSame(60, TreblleConfig::SDK_VERSION); + } + + public function testDisabled(): void + { + $config = new TreblleConfig('sdk-token', 'api-key', enabled: false); + + self::assertFalse($config->enabled); + } +} diff --git a/tests/Unit/Filter/PathMatcherTest.php b/tests/Unit/Filter/PathMatcherTest.php new file mode 100644 index 0000000..ccea29c --- /dev/null +++ b/tests/Unit/Filter/PathMatcherTest.php @@ -0,0 +1,90 @@ +matches('/health')); + self::assertTrue($matcher->matches('/status')); + self::assertFalse($matcher->matches('/api/users')); + self::assertFalse($matcher->matches('/health-check')); + } + + public function testExactMatchIsCaseInsensitive(): void + { + $matcher = new PathMatcher(['/Health']); + + self::assertTrue($matcher->matches('/health')); + self::assertTrue($matcher->matches('/HEALTH')); + } + + public function testWildcardMatch(): void + { + $matcher = new PathMatcher(['admin/*']); + + self::assertTrue($matcher->matches('admin/users')); + self::assertTrue($matcher->matches('admin/settings/edit')); + self::assertFalse($matcher->matches('/api/users')); + } + + public function testWildcardWithLeadingSlash(): void + { + $matcher = new PathMatcher(['/admin/*']); + + self::assertTrue($matcher->matches('/admin/dashboard')); + self::assertTrue($matcher->matches('/admin/users/123')); + self::assertFalse($matcher->matches('/user/admin')); + } + + public function testSingleCharacterWildcard(): void + { + $matcher = new PathMatcher(['/api/v?/users']); + + self::assertTrue($matcher->matches('/api/v1/users')); + self::assertTrue($matcher->matches('/api/v2/users')); + self::assertFalse($matcher->matches('/api/v10/users')); + } + + public function testRegexMatch(): void + { + $matcher = new PathMatcher(['/^\/api\/v\d+\/.*$/']); + + self::assertTrue($matcher->matches('/api/v1/users')); + self::assertTrue($matcher->matches('/api/v2/posts/123')); + self::assertFalse($matcher->matches('/web/users')); + } + + public function testEmptyExcludedPaths(): void + { + $matcher = new PathMatcher([]); + + self::assertFalse($matcher->matches('/health')); + self::assertFalse($matcher->matches('/anything')); + } + + public function testNoMatchReturnsTrue(): void + { + $matcher = new PathMatcher(['/health', '/status']); + + self::assertFalse($matcher->matches('/api/users')); + } + + public function testMultiplePatterns(): void + { + $matcher = new PathMatcher(['/health', 'admin/*', '/^\/debug\//']); + + self::assertTrue($matcher->matches('/health')); + self::assertTrue($matcher->matches('admin/panel')); + self::assertTrue($matcher->matches('/debug/info')); + self::assertFalse($matcher->matches('/api/data')); + } +} diff --git a/tests/Unit/Filter/RequestTypeFilterTest.php b/tests/Unit/Filter/RequestTypeFilterTest.php new file mode 100644 index 0000000..430ab69 --- /dev/null +++ b/tests/Unit/Filter/RequestTypeFilterTest.php @@ -0,0 +1,66 @@ +filter = new RequestTypeFilter(); + } + + public function testSkipsStaticFileExtensions(): void + { + self::assertTrue($this->filter->shouldSkip('/style.css')); + self::assertTrue($this->filter->shouldSkip('/app.js')); + self::assertTrue($this->filter->shouldSkip('/index.html')); + self::assertTrue($this->filter->shouldSkip('/favicon.ico')); + self::assertTrue($this->filter->shouldSkip('/logo.png')); + self::assertTrue($this->filter->shouldSkip('/font.woff2')); + self::assertTrue($this->filter->shouldSkip('/.env')); + } + + public function testSkipsFileExtensionCaseInsensitive(): void + { + self::assertTrue($this->filter->shouldSkip('/style.CSS')); + self::assertTrue($this->filter->shouldSkip('/image.PNG')); + } + + public function testDoesNotSkipJsonApiRoutes(): void + { + self::assertFalse($this->filter->shouldSkip('/api/users', 'application/json')); + self::assertFalse($this->filter->shouldSkip('/api/v1/posts', 'application/json; charset=utf-8')); + } + + public function testSkipsNonJsonContentType(): void + { + self::assertTrue($this->filter->shouldSkip('/api/page', 'text/html')); + self::assertTrue($this->filter->shouldSkip('/api/data', 'text/plain')); + self::assertTrue($this->filter->shouldSkip('/api/style', 'text/css')); + } + + public function testAllowsRequestWithoutContentTypeYet(): void + { + // No content type means we haven't seen the response yet — don't skip + self::assertFalse($this->filter->shouldSkip('/api/users', null)); + } + + public function testSkipsEnvFile(): void + { + self::assertTrue($this->filter->shouldSkip('/.env')); + self::assertTrue($this->filter->shouldSkip('/config/.env')); + } + + public function testDoesNotSkipApiPathWithNoExtension(): void + { + self::assertFalse($this->filter->shouldSkip('/api/v1/health')); + self::assertFalse($this->filter->shouldSkip('/users/123')); + } +} diff --git a/tests/Unit/Masking/SensitiveDataMaskerTest.php b/tests/Unit/Masking/SensitiveDataMaskerTest.php new file mode 100644 index 0000000..4229ef3 --- /dev/null +++ b/tests/Unit/Masking/SensitiveDataMaskerTest.php @@ -0,0 +1,139 @@ +maskData(['password' => 'secret123']); + + self::assertSame('*********', $result['password']); + } + + public function testMaskingPreservesLength(): void + { + $masker = new SensitiveDataMasker(['token']); + + $result = $masker->maskData(['token' => 'abc']); + + self::assertSame('***', $result['token']); + } + + public function testMaskingIsCaseInsensitive(): void + { + $masker = new SensitiveDataMasker(['password']); + + $result = $masker->maskData(['Password' => 'value', 'PASSWORD' => 'value2']); + + self::assertSame('*****', $result['Password']); + self::assertSame('******', $result['PASSWORD']); + } + + public function testDoesNotMaskNonMatchingKey(): void + { + $masker = new SensitiveDataMasker(['password']); + + $result = $masker->maskData(['username' => 'alice', 'email' => 'alice@example.com']); + + self::assertSame('alice', $result['username']); + self::assertSame('alice@example.com', $result['email']); + } + + public function testEmptyKeywordsSkipsMasking(): void + { + $masker = new SensitiveDataMasker([]); + + $result = $masker->maskData(['password' => 'secret', 'ssn' => '123-45-6789']); + + self::assertSame('secret', $result['password']); + self::assertSame('123-45-6789', $result['ssn']); + } + + public function testRecursiveMasking(): void + { + $masker = new SensitiveDataMasker(['password']); + + $result = $masker->maskData([ + 'user' => [ + 'name' => 'Alice', + 'password' => 'hunter2', + ], + ]); + + self::assertSame('Alice', $result['user']['name']); + self::assertSame('*******', $result['user']['password']); + } + + public function testMasksAuthorizationHeaderPreservingScheme(): void + { + $masker = new SensitiveDataMasker(['authorization']); + + $result = $masker->maskHeaders(['Authorization' => 'Bearer my-secret-token']); + + self::assertSame('Bearer ***************', $result['Authorization']); + } + + public function testMasksBasicAuthorizationHeader(): void + { + $masker = new SensitiveDataMasker(['authorization']); + + $result = $masker->maskHeaders(['Authorization' => 'Basic dXNlcjpwYXNz']); + + self::assertSame('Basic ************', $result['Authorization']); + } + + public function testMasksXApiKeyHeader(): void + { + $masker = new SensitiveDataMasker(['x-api-key']); + + $result = $masker->maskHeaders(['x-api-key' => 'my-key-123']); + + self::assertSame('**********', $result['x-api-key']); + } + + public function testAuthorizationAndXApiKeyNotMaskedWhenNotInKeywords(): void + { + $masker = new SensitiveDataMasker([]); + + $result = $masker->maskHeaders(['Authorization' => 'Bearer token', 'x-api-key' => 'my-key']); + + self::assertSame('Bearer token', $result['Authorization']); + self::assertSame('my-key', $result['x-api-key']); + } + + public function testRedactsBase64Images(): void + { + $masker = new SensitiveDataMasker([]); + + $result = $masker->maskData(['avatar' => 'data:image/png;base64,iVBORw0KGgo=']); + + self::assertSame('[image]', $result['avatar']); + } + + public function testMasksNumericValues(): void + { + $masker = new SensitiveDataMasker(['cc']); + + $result = $masker->maskData(['cc' => 1234567890123456]); + + self::assertSame('****************', $result['cc']); + } + + public function testNonMatchingHeadersPassThrough(): void + { + $masker = new SensitiveDataMasker([]); + + $result = $masker->maskHeaders(['Content-Type' => 'application/json', 'Accept' => 'application/json']); + + self::assertSame('application/json', $result['Content-Type']); + self::assertSame('application/json', $result['Accept']); + } +} diff --git a/tests/Unit/Payload/PayloadBuilderTest.php b/tests/Unit/Payload/PayloadBuilderTest.php new file mode 100644 index 0000000..d2ca28a --- /dev/null +++ b/tests/Unit/Payload/PayloadBuilderTest.php @@ -0,0 +1,238 @@ +maskedKeywords); + + $requestCollector = $this->createMock(RequestCollector::class); + $requestCollector->method('collect')->willReturn([ + 'timestamp' => '2024-01-01 00:00:00', + 'ip' => '127.0.0.1', + 'url' => 'https://example.com/api/users', + 'user_agent' => 'TestAgent/1.0', + 'method' => 'GET', + 'headers' => ['Content-Type' => 'application/json'], + 'body' => $configOptions['requestBody'] ?? [], + 'route_path' => null, + 'query' => [], + ]); + + $responseCollector = $this->createMock(ResponseCollector::class); + $responseCollector->method('collect')->willReturn([ + 'headers' => ['Content-Type' => 'application/json'], + 'code' => 200, + 'size' => 42, + 'load_time' => 12.5, + 'body' => $configOptions['responseBody'] ?? ['status' => 'ok'], + 'content_type' => 'application/json', + ]); + + $serverCollector = $this->createMock(ServerCollector::class); + $serverCollector->method('collect')->willReturn([ + 'ip' => '10.0.0.1', + 'timezone' => 'UTC', + 'software' => 'nginx', + 'protocol' => 'HTTP/1.1', + 'os' => ['name' => 'Linux', 'release' => '5.15', 'architecture' => 'x86_64'], + ]); + + $builder = new PayloadBuilder( + config: $config, + masker: $masker, + server: $serverCollector, + language: new LanguageCollector(), + request: $requestCollector, + response: $responseCollector, + errors: new ErrorCollector(), + ); + + $gzipped = $builder->build(); + + if ($gzipped === false) { + return false; + } + + $json = gzdecode($gzipped); + self::assertNotFalse($json); + + return json_decode($json, true); + } + + public function testPayloadHasRequiredTopLevelKeys(): void + { + $payload = $this->buildPayload(); + + self::assertIsArray($payload); + self::assertArrayHasKey('api_key', $payload); + self::assertArrayHasKey('project_id', $payload); + self::assertArrayHasKey('sdk', $payload); + self::assertArrayHasKey('version', $payload); + self::assertArrayHasKey('data', $payload); + } + + public function testPayloadMapsCredentialsCorrectly(): void + { + $payload = $this->buildPayload([ + 'sdkToken' => 'my-sdk-token', + 'apiKey' => 'my-api-key', + ]); + + self::assertIsArray($payload); + // sdkToken → api_key in payload + self::assertSame('my-sdk-token', $payload['api_key']); + // apiKey → project_id in payload + self::assertSame('my-api-key', $payload['project_id']); + } + + public function testPayloadSdkIdentifier(): void + { + $payload = $this->buildPayload(); + + self::assertIsArray($payload); + self::assertSame('php', $payload['sdk']); + self::assertSame(60, $payload['version']); + } + + public function testPayloadDataStructure(): void + { + $payload = $this->buildPayload(); + + self::assertIsArray($payload); + $data = $payload['data']; + self::assertArrayHasKey('server', $data); + self::assertArrayHasKey('language', $data); + self::assertArrayHasKey('request', $data); + self::assertArrayHasKey('response', $data); + self::assertArrayHasKey('errors', $data); + } + + public function testPayloadRequestFields(): void + { + $payload = $this->buildPayload(); + + self::assertIsArray($payload); + $request = $payload['data']['request']; + + self::assertSame('2024-01-01 00:00:00', $request['timestamp']); + self::assertSame('127.0.0.1', $request['ip']); + self::assertSame('https://example.com/api/users', $request['url']); + self::assertSame('GET', $request['method']); + self::assertNull($request['route_path']); + } + + public function testPayloadResponseFields(): void + { + $payload = $this->buildPayload(); + + self::assertIsArray($payload); + $response = $payload['data']['response']; + + self::assertSame(200, $response['code']); + self::assertSame(42, $response['size']); + self::assertSame(12.5, $response['load_time']); + } + + public function testSensitiveFieldsAreMaskedInPayload(): void + { + $payload = $this->buildPayload([ + 'maskedKeywords' => ['password'], + 'requestBody' => ['username' => 'alice', 'password' => 'secret'], + ]); + + self::assertIsArray($payload); + $body = $payload['data']['request']['body']; + + self::assertSame('alice', $body['username']); + self::assertSame('******', $body['password']); + } + + public function testLanguageSection(): void + { + $payload = $this->buildPayload(); + + self::assertIsArray($payload); + $language = $payload['data']['language']; + + self::assertSame('php', $language['name']); + self::assertSame(PHP_VERSION, $language['version']); + } + + public function testPayloadIsGzipCompressed(): void + { + $config = new TreblleConfig('sdk-token', 'api-key'); + $masker = new SensitiveDataMasker([]); + + $requestCollector = $this->createMock(RequestCollector::class); + $requestCollector->method('collect')->willReturn([ + 'timestamp' => '2024-01-01 00:00:00', + 'ip' => '127.0.0.1', + 'url' => 'https://example.com/api', + 'user_agent' => '', + 'method' => 'GET', + 'headers' => [], + 'body' => [], + 'route_path' => null, + 'query' => [], + ]); + + $responseCollector = $this->createMock(ResponseCollector::class); + $responseCollector->method('collect')->willReturn([ + 'headers' => [], + 'code' => 200, + 'size' => 0, + 'load_time' => 0.0, + 'body' => [], + 'content_type' => null, + ]); + + $serverCollector = $this->createMock(ServerCollector::class); + $serverCollector->method('collect')->willReturn([ + 'ip' => 'bogon', + 'timezone' => 'UTC', + 'software' => null, + 'protocol' => null, + 'os' => ['name' => null, 'release' => null, 'architecture' => null], + ]); + + $builder = new PayloadBuilder( + config: $config, + masker: $masker, + server: $serverCollector, + language: new LanguageCollector(), + request: $requestCollector, + response: $responseCollector, + errors: new ErrorCollector(), + ); + + $result = $builder->build(); + + self::assertIsString($result); + // Verify it's valid gzip + $decoded = gzdecode($result); + self::assertNotFalse($decoded); + $json = json_decode($decoded, true); + self::assertIsArray($json); + } +}