Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 27 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ registerUser($userData);
$perfbase->stopTraceSpan('user_registration');

// Submit the trace data to Perfbase
$perfbase->submitTrace();
$result = $perfbase->submitTrace();
```

### Advanced Usage with Attributes
Expand Down Expand Up @@ -144,7 +144,7 @@ try {
}

// Submit trace data
$perfbase->submitTrace();
$result = $perfbase->submitTrace();
```

### Multiple Concurrent Spans
Expand All @@ -169,7 +169,7 @@ accessCache();
$perfbase->stopTraceSpan('cache_operations');

// Submit all trace data
$perfbase->submitTrace();
$result = $perfbase->submitTrace();
```

## Configuration Options
Expand Down Expand Up @@ -265,11 +265,23 @@ Add a custom attribute to the current trace.
$perfbase->setAttribute('cache_hit_ratio', '0.85');
```

#### `submitTrace(): void`
Submit collected profiling data to Perfbase and reset the session.
#### `submitTrace(): SubmitResult`
Submit collected profiling data to Perfbase. Resets the session on success; preserves trace state on failure so callers can decide whether to retry or discard.

The returned `SubmitResult` provides:
- `isSuccess(): bool` — delivery confirmed
- `isRetryable(): bool` — transient failure (network error, 429, 5xx)
- `isPermanentFailure(): bool` — non-retryable (401, 403, 400, etc.)
- `getStatusCode(): ?int` — HTTP status code if a response was received
- `getMessage(): string` — human-readable description

```php
$perfbase->submitTrace();
$result = $perfbase->submitTrace();

if (!$result->isSuccess()) {
// Handle failure — trace state is preserved for retry
error_log("Trace submission failed: " . $result->getMessage());
}
```

#### `getTraceData(string $spanName = ''): string`
Expand Down Expand Up @@ -327,6 +339,15 @@ try {
error_log("Perfbase extension not available: " . $e->getMessage());
// Your application continues normally
}

// Submission failures are returned as SubmitResult, not thrown
$result = $perfbase->submitTrace();
if ($result->isRetryable()) {
// Transient failure — safe to retry later
} elseif ($result->isPermanentFailure()) {
// Non-retryable — check API key, payload, etc.
error_log("Permanent submission failure: " . $result->getMessage());
}
```

## Best Practices
Expand Down
22 changes: 8 additions & 14 deletions src/Http/ApiClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,8 @@

use GuzzleHttp\Client as GuzzleClient;
use Perfbase\SDK\Config;
use Perfbase\SDK\Http\HttpClientInterface;
use Perfbase\SDK\Http\GuzzleHttpClient;
use Perfbase\SDK\Perfbase;
use Perfbase\SDK\SubmitResult;

class ApiClient
{
Expand Down Expand Up @@ -42,18 +41,15 @@ public function __construct(Config $config, ?HttpClientInterface $httpClient = n
if ($httpClient !== null) {
$this->httpClient = $httpClient;
} else {
// Create default HTTP client
/** @var array<string, mixed> $httpClientConfig */
$httpClientConfig = [];
$httpClientConfig['base_uri'] = $config->api_url;
$httpClientConfig['timeout'] = $config->timeout;

// Set up proxy if configured
if ($config->proxy) {
$httpClientConfig['proxy'] = $config->proxy;
}

// Set up the HTTP client
$guzzleClient = new GuzzleClient($httpClientConfig);
$this->httpClient = new GuzzleHttpClient($guzzleClient);
}
Expand All @@ -63,29 +59,27 @@ public function __construct(Config $config, ?HttpClientInterface $httpClient = n
* Submits a trace to the Perfbase API
*
* @param string $perfData Data to send in the request body
* @return void
* @return SubmitResult
*/
public function submitTrace(string $perfData): void
public function submitTrace(string $perfData): SubmitResult
{
$this->submit('/v1/submit', $perfData);
return $this->submit('/v1/submit', $perfData);
}

/**
* Sends a POST request to the specified API endpoint
*
* @param string $endpoint API endpoint to send the request to
* @param string $perfData Data to send in the request body
* @return void
* @return SubmitResult
*/
private function submit(string $endpoint, string $perfData): void
private function submit(string $endpoint, string $perfData): SubmitResult
{
// Prepare request options
$options = [
'headers' => array_merge($this->defaultHeaders, []),
'headers' => $this->defaultHeaders,
'body' => $perfData,
];

$this->httpClient->post($endpoint, $options);
return $this->httpClient->post($endpoint, $options);
}

}
49 changes: 44 additions & 5 deletions src/Http/GuzzleHttpClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
namespace Perfbase\SDK\Http;

use GuzzleHttp\Client as GuzzleClient;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\RequestException;
use Perfbase\SDK\SubmitResult;
use Throwable;

class GuzzleHttpClient implements HttpClientInterface
Expand All @@ -14,13 +17,49 @@ public function __construct(GuzzleClient $client)
$this->client = $client;
}

public function post(string $uri, array $options = []): void
public function post(string $uri, array $options = []): SubmitResult
{
try {
$this->client->post($uri, $options);
$response = $this->client->post($uri, $options);
$statusCode = $response->getStatusCode();

if ($statusCode >= 200 && $statusCode < 300) {
return SubmitResult::success($statusCode);
}

// Non-2xx that Guzzle didn't throw on (http_errors disabled)
return $this->classifyHttpStatus($statusCode);
} catch (ConnectException $e) {
// Network-level failure: DNS, timeout, connection refused — always retryable
return SubmitResult::retryableFailure(null, $e->getMessage());
} catch (RequestException $e) {
// HTTP error response (4xx/5xx)
$response = $e->getResponse();
$statusCode = $response !== null ? $response->getStatusCode() : null;
return $this->classifyHttpStatus($statusCode, $e->getMessage());
} catch (Throwable $e) {
// Silent failure as per original implementation
// Could be made configurable in the future
// Unexpected failure — treat as retryable
return SubmitResult::retryableFailure(null, $e->getMessage());
}
}
}

/**
* Classify an HTTP status code into a submit result.
*
* 429 and 5xx are retryable. 4xx (except 429) are permanent.
*/
private function classifyHttpStatus(?int $statusCode, string $message = ''): SubmitResult
{
if ($statusCode === null) {
return SubmitResult::retryableFailure(null, $message);
}

// Rate limited or server errors — retryable
if ($statusCode === 429 || $statusCode >= 500) {
return SubmitResult::retryableFailure($statusCode, $message);
}

// Client errors (400, 401, 403, 404, etc.) — permanent
return SubmitResult::permanentFailure($statusCode, $message);
}
}
12 changes: 7 additions & 5 deletions src/Http/HttpClientInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@

namespace Perfbase\SDK\Http;

use Perfbase\SDK\SubmitResult;

interface HttpClientInterface
{
/**
* Send a POST request
* Send a POST request and return a structured result.
*
* @param string $uri The URI to send the request to
* @param array<string, mixed> $options Request options including headers, body, etc.
* @return void
* @throws \Throwable If the request fails
* @return SubmitResult
*/
public function post(string $uri, array $options = []): void;
}
public function post(string $uri, array $options = []): SubmitResult;
}
24 changes: 17 additions & 7 deletions src/Perfbase.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Perfbase\SDK\Extension\ExtensionInterface;
use Perfbase\SDK\Extension\PerfbaseExtension;
use Perfbase\SDK\Http\ApiClient;
use Perfbase\SDK\SubmitResult;

/**
* Main client class for the Perfbase SDK
Expand Down Expand Up @@ -157,15 +158,24 @@ public function setFlags(int $flags): void
}

/**
* Sends collected profiling data to the API
* @return void
* Sends collected profiling data to the API.
*
* Resets trace state on success. On failure, trace state is preserved
* so the caller can decide whether to retry or discard.
*
* @return SubmitResult
*/
public function submitTrace(): void
public function submitTrace(): SubmitResult
{
$this->apiClient->submitTrace(
$this->getTraceData()
);
$this->reset();
$payload = TracePayloadFactory::build($this->getTraceData());

$result = $this->apiClient->submitTrace($payload);

if ($result->isSuccess()) {
$this->reset();
}

return $result;
}

/**
Expand Down
76 changes: 76 additions & 0 deletions src/SubmitResult.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php

namespace Perfbase\SDK;

/**
* Represents the outcome of a trace submission attempt.
*
* Callers should check the status to decide whether to retry, reset, or surface an error.
*/
class SubmitResult
{
public const STATUS_SUCCESS = 'success';
public const STATUS_RETRYABLE_FAILURE = 'retryable_failure';
public const STATUS_PERMANENT_FAILURE = 'permanent_failure';

/** @var string One of the STATUS_* constants */
private string $status;

/** @var int|null HTTP status code, if a response was received */
private ?int $statusCode;

/** @var string Human-readable description of what happened */
private string $message;

private function __construct(string $status, ?int $statusCode, string $message)
{
$this->status = $status;
$this->statusCode = $statusCode;
$this->message = $message;
}

public static function success(?int $statusCode = 200, string $message = ''): self
{
return new self(self::STATUS_SUCCESS, $statusCode, $message);
}

public static function retryableFailure(?int $statusCode = null, string $message = ''): self
{
return new self(self::STATUS_RETRYABLE_FAILURE, $statusCode, $message);
}

public static function permanentFailure(?int $statusCode = null, string $message = ''): self
{
return new self(self::STATUS_PERMANENT_FAILURE, $statusCode, $message);
}

public function isSuccess(): bool
{
return $this->status === self::STATUS_SUCCESS;
}

public function isRetryable(): bool
{
return $this->status === self::STATUS_RETRYABLE_FAILURE;
}

public function isPermanentFailure(): bool
{
return $this->status === self::STATUS_PERMANENT_FAILURE;
}

public function getStatus(): string
{
return $this->status;
}

public function getStatusCode(): ?int
{
return $this->statusCode;
}

public function getMessage(): string
{
return $this->message;
}
}
54 changes: 54 additions & 0 deletions src/TracePayloadFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

namespace Perfbase\SDK;

use Perfbase\SDK\Exception\PerfbaseException;

/**
* Builds the submission payload from extension output.
*
* The extension returns JSON like {"v":1,"p":"<base64>"}. This factory
* owns the outer envelope — it validates structure, injects the client
* timestamp, and produces the final JSON body for the receiver.
*/
class TracePayloadFactory
{
/**
* Build a submission payload from raw extension output.
*
* @param string $extensionOutput Raw JSON from perfbase_get_data()
* @return string JSON payload ready for HTTP submission
* @throws PerfbaseException If the extension output is malformed
*/
public static function build(string $extensionOutput): string
{
if ($extensionOutput === '') {
throw new PerfbaseException('Extension returned empty trace data');
}

/** @var mixed $decoded */
$decoded = json_decode($extensionOutput, true);

if (!is_array($decoded)) {
throw new PerfbaseException('Extension returned invalid JSON');
}

if (!isset($decoded['v']) || !is_int($decoded['v'])) {
throw new PerfbaseException('Extension output missing version field (v)');
}

if (!isset($decoded['p']) || !is_string($decoded['p'])) {
throw new PerfbaseException('Extension output missing payload field (p)');
}

// Inject client timestamp so the receiver knows when the trace was created
$decoded['d'] = gmdate('Y-m-d\TH:i:s\Z');

$json = json_encode($decoded);
if ($json === false) {
throw new PerfbaseException('Failed to encode submission payload');
}

return $json;
}
}
Loading
Loading