Backend Setup: PHP
This guide covers PHP instrumentation for Sailfish Enterprise. Key differences from local development:
- API key: Use your Enterprise API key (shown as
"<ApiKey />"below -- auto-replaced when you're logged in) - GraphQL endpoint: Do not set it -- the SDK defaults to the Sailfish cloud endpoint automatically
- Service identifier: Use the
<org>/<repo>/<path-to-this-file>format (e.g.,acme-corp/web-api/index.php) - No environment gating: Enterprise telemetry runs in all environments (staging, production)
If you connected GitHub and received Auto-Installation PRs, the API key, service identifier, and graphql endpoint are already configured for you.
This guide walks you through instrumenting a PHP application with the SF Veritas SDK. PHP instrumentation captures logs, exceptions with stack traces, function execution spans, outbound HTTP requests, and print/echo output.
Getting Your API Key
- Open the Sailfish dashboard
- Log in with your enterprise email
- Navigate to Settings > Configuration
- Copy your company's API key
Installation
1. Add the Sailfish Repository
Add the Sailfish Composer repository to your composer.json:
{
"repositories": [
{
"type": "composer",
"url": "https://satis.sailfishqa.com/repo"
}
]
}
2. Install the Package
composer require sailfish/sf-veritas
- PHP 8.1 or higher
ext-curlextension (usually enabled by default)ext-jsonextension (usually enabled by default)
Basic Setup
Add the following to your application's entry point:
<?php
use Sailfish\SfVeritas\SetupInterceptors;
require_once __DIR__ . '/vendor/autoload.php';
SetupInterceptors::init([
'api_key' => '<see-api-key-from-your-account-settings-page>',
'service_identifier' => 'acme-corp/php-api/public/index.php', // Format: <org>/<repo>/<path-to-this-file>
'service_version' => '1.0.0',
]);
// Your application code continues below...
Configuration Options
| Option | Type | Required | Description |
|---|---|---|---|
api_key | string | Yes | Your Sailfish Enterprise API key |
service_identifier | string | Yes | Unique identifier in <org>/<repo>/<path> format |
service_version | string | No | Version of your service |
service_display_name | string | No | Display name in the UI |
git_sha | string | No | Git commit SHA (auto-detected if not set) |
git_org | string | No | Git organization (auto-detected if not set) |
git_repo | string | No | Git repository name (auto-detected if not set) |
domains_to_not_propagate_headers_to | array | No | Domains to skip header propagation |
enable_log_capture | bool | No | Enable Monolog log capture (default: true) |
enable_exception_capture | bool | No | Enable exception capture (default: true) |
enable_print_capture | bool | No | Enable echo/print capture (default: true) |
debug | bool | No | Enable debug logging (default: false) |
What Gets Captured Automatically
Once SetupInterceptors::init() is called, these are captured with zero additional code:
- Logs -- All Monolog log entries (info, warning, error, etc.)
- Print statements -- All
echo,print, andvar_dumpoutput - Exceptions -- Uncaught exceptions with full stack traces
- Inbound HTTP requests -- Via framework middleware (Laravel/Symfony)
- Outbound HTTP requests via the framework's native HTTP client -- Laravel's
Httpfacade and Symfony's DI-injectedHttpClientInterface. See the HTTP Client Instrumentation section -- other HTTP mechanisms (raw Guzzle, cURL,file_get_contents, third-party SDKs) require explicit wiring per client.
PHP has no global HTTP hook. The SDK auto-instruments only the framework's native HTTP service. Direct new GuzzleHttp\Client(), curl_exec(), file_get_contents(), and Guzzle clients embedded inside third-party SDKs (AWS SDK, Stripe, Twilio, Laravel packages, etc.) are NOT instrumented by default. Each one needs the recipe shown in the HTTP Client Instrumentation section below. If distributed traces stop at the PHP boundary, this is almost always why.
Framework Integration
Laravel
Laravel integration is automatic via the included Service Provider.
1. Publish the Configuration
php artisan vendor:publish --tag=sfveritas-config
This creates config/sfveritas.php where you can customize settings.
2. Set Environment Variables
Add to your .env file:
# .env
SAILFISH_API_KEY=<see-api-key-from-your-account-settings-page>
SAILFISH_SERVICE_IDENTIFIER=acme-corp/laravel-app/config/sfveritas.php
That's it. The Service Provider automatically:
- Registers the request tracking middleware
- Attaches a Monolog log handler
- Adds Guzzle middleware for outbound HTTP tracing
- Registers the exception reporter
- Initializes all collectors
3. Run Your Application
php artisan serve
Symfony
Symfony integration uses a Bundle that registers automatically.
1. Register the Bundle
Add to config/bundles.php:
<?php
return [
// ... other bundles
Sailfish\SfVeritas\Bundles\Symfony\SfVeritasBundle::class => ['all' => true],
];
2. Set Environment Variables
Add to your .env.local:
# .env.local
SAILFISH_API_KEY=<see-api-key-from-your-account-settings-page>
SAILFISH_SERVICE_IDENTIFIER=acme-corp/symfony-app/config/bundles.php
3. Run Your Application
symfony serve
# or
php -S localhost:8000 -t public/
Standalone PHP
For non-framework PHP applications, call SetupInterceptors::init() directly:
<?php
require_once __DIR__ . '/vendor/autoload.php';
use Sailfish\SfVeritas\SetupInterceptors;
SetupInterceptors::init([
'api_key' => '<see-api-key-from-your-account-settings-page>',
'service_identifier' => 'acme-corp/php-app/index.php', // Format: <org>/<repo>/<path-to-this-file>
'service_version' => '1.0.0',
]);
// Your application code...
Slim
<?php
// public/index.php
require __DIR__ . '/../vendor/autoload.php';
use Sailfish\SfVeritas\SetupInterceptors;
use Slim\Factory\AppFactory;
SetupInterceptors::init([
'api_key' => '<see-api-key-from-your-account-settings-page>',
'service_identifier' => 'acme-corp/slim-api/public/index.php', // Format: <org>/<repo>/<path-to-this-file>
'service_version' => '1.0.0',
]);
$app = AppFactory::create();
$app->get('/api/users', function ($request, $response) {
$response->getBody()->write(json_encode(['users' => []]));
return $response->withHeader('Content-Type', 'application/json');
});
$app->run();
CodeIgniter
<?php
// app/Config/Events.php (or your bootstrap file)
use Sailfish\SfVeritas\SetupInterceptors;
SetupInterceptors::init([
'api_key' => '<see-api-key-from-your-account-settings-page>',
'service_identifier' => 'acme-corp/ci-app/app/Config/Events.php', // Format: <org>/<repo>/<path-to-this-file>
'service_version' => '1.0.0',
]);
CakePHP
<?php
// config/bootstrap.php (add at the end)
use Sailfish\SfVeritas\SetupInterceptors;
SetupInterceptors::init([
'api_key' => '<see-api-key-from-your-account-settings-page>',
'service_identifier' => 'acme-corp/cake-app/config/bootstrap.php', // Format: <org>/<repo>/<path-to-this-file>
'service_version' => '1.0.0',
]);
Yii
<?php
// web/index.php (before Yii::createWebApplication)
require __DIR__ . '/../vendor/autoload.php';
use Sailfish\SfVeritas\SetupInterceptors;
SetupInterceptors::init([
'api_key' => '<see-api-key-from-your-account-settings-page>',
'service_identifier' => 'acme-corp/yii-app/web/index.php', // Format: <org>/<repo>/<path-to-this-file>
'service_version' => '1.0.0',
]);
// Then continue with Yii bootstrap...
The SDK automatically detects which PHP framework is running (Laravel, Symfony, Slim, CodeIgniter, CakePHP, Yii, Laminas, or Phalcon) and reports it alongside your service's telemetry. No extra configuration needed.
Function Tracing
Use PHP 8 Attributes to control which functions appear in the Flamechart:
use Sailfish\SfVeritas\Attributes\CaptureSpan;
use Sailfish\SfVeritas\Attributes\SkipTracing;
class OrderService
{
#[CaptureSpan]
public function processOrder(string $orderId): array
{
// This function will be traced in the Flamechart
return $this->doWork($orderId);
}
#[SkipTracing]
private function internalHelper(): void
{
// This function won't be traced
}
}
Attribute Options
#[CaptureSpan(
captureArgs: true, // Capture function arguments
captureReturn: true, // Capture return values
sampleRate: 1.0, // Sampling rate (0.0 to 1.0)
argLimitMb: 1, // Max argument capture size in MB
returnLimitMb: 1, // Max return value capture size in MB
autocaptureChildren: true // Auto-capture all child function calls
)]
public function tracedFunction(): void
{
// ...
}
HTTP Client Instrumentation
Outbound HTTP tracing requires wiring per HTTP mechanism -- PHP provides no global hook that lets us intercept every outbound request automatically. The framework integration (Laravel Service Provider / Symfony Bundle) only covers the framework's native HTTP service. Every other mechanism needs explicit wiring so the X-Sf3-Rid trace header propagates and request telemetry is captured.
Coverage Matrix
| # | HTTP Mechanism | Laravel | Symfony | Standalone |
|---|---|---|---|---|
| 1 | Framework-native HTTP client | ✅ Automatic | ✅ Automatic | N/A |
| 2 | Direct Guzzle (new GuzzleHttp\Client()) | ❌ Manual | ❌ Manual | ❌ Manual |
| 3 | Third-party SDKs with embedded Guzzle (AWS, Stripe, Twilio, etc.) | ❌ Manual | ❌ Manual | ❌ Manual |
| 4 | Symfony HttpClient (manually constructed) | ❌ Manual | ❌ Manual* | ❌ Manual |
| 5 | curl_exec() / curl_multi_exec() | ❌ Manual | ❌ Manual | ❌ Manual |
| 6 | file_get_contents() HTTP | ❌ Manual | ❌ Manual | ❌ Manual |
* Only DI-injected HttpClientInterface is auto-decorated. Clients created with HttpClient::create() are not.
1. Framework-native HTTP client (Laravel & Symfony)
Nothing to do. These are wired automatically by the Service Provider / Bundle.
Laravel -- Http facade:
use Illuminate\Support\Facades\Http;
$response = Http::get('https://api.example.com/users'); // ✅ instrumented
$response = Http::post('https://api.example.com/orders', [...]); // ✅ instrumented
The Service Provider registers NetworkRequestCollector::middleware() and OutboundHeaderInjector::middleware() as globalMiddleware on Illuminate\Http\Client\Factory, so every request through Http::* carries the trace header.
Symfony -- DI-injected HttpClientInterface:
use Symfony\Contracts\HttpClient\HttpClientInterface;
class MyService
{
public function __construct(private HttpClientInterface $client) {}
public function fetch(): array
{
return $this->client->request('GET', 'https://api.example.com/data')->toArray(); // ✅ instrumented
}
}
The Bundle decorates the http_client service with TracingHttpClient, so every client resolved from the container is instrumented.
- Laravel: calls to
new GuzzleHttp\Client()inside a controller or service -- see section 2 below. - Symfony: clients built with
HttpClient::create()directly -- see section 4 below. - Either framework: third-party SDKs that carry their own Guzzle/HTTP client -- see section 3 below.
2. Direct Guzzle client (all frameworks)
Any code that does new GuzzleHttp\Client(...) bypasses Laravel's Http facade and Symfony's DI container. You must attach Sailfish middleware to the client's handler stack.
Laravel / Symfony / Standalone -- same recipe:
use GuzzleHttp\Client;
use Sailfish\SfVeritas\Collectors\SailfishGuzzle;
// Option A: Create a fully instrumented client in one call (easiest)
$client = SailfishGuzzle::createClient(['base_uri' => 'https://api.example.com']);
// Option B: Build the handler stack manually (for custom middleware chains)
$client = new Client([
'handler' => SailfishGuzzle::createStack(),
'base_uri' => 'https://api.example.com',
]);
// Option C: Wrap an existing client (returns a new Client; original is not mutated)
$existingClient = new Client(['base_uri' => 'https://api.example.com']);
$client = SailfishGuzzle::wrapClient($existingClient);
$response = $client->get('/users'); // ✅ instrumented
Laravel tip -- bind an instrumented Guzzle client in the service container so any service resolving GuzzleHttp\Client receives an instrumented instance:
// app/Providers/AppServiceProvider.php
use GuzzleHttp\Client;
use Sailfish\SfVeritas\Collectors\SailfishGuzzle;
public function register(): void
{
$this->app->bind(Client::class, fn () => SailfishGuzzle::createClient());
}
Symfony tip -- register an instrumented Guzzle client as a service:
# config/services.yaml
services:
GuzzleHttp\Client:
factory: ['Sailfish\SfVeritas\Collectors\SailfishGuzzle', 'createClient']
arguments: [[]]
3. Third-party SDKs with embedded Guzzle
SDKs like AWS SDK, Stripe, Twilio, GitHub (knplabs/github-api), Mailgun, many Laravel packages, etc. construct their own Guzzle client internally. These are never touched by Laravel's global middleware or Symfony's DI decoration -- you must pass an instrumented Guzzle client/handler into each SDK.
Most SDKs expose a constructor option for this. The three common patterns:
Pattern A -- SDK accepts a Guzzle Client:
use Sailfish\SfVeritas\Collectors\SailfishGuzzle;
// Example: Stripe
\Stripe\Stripe::setHttpClient(
new \Stripe\HttpClient\GuzzleClient(SailfishGuzzle::createClient())
);
Pattern B -- SDK accepts a Guzzle HandlerStack:
use Aws\S3\S3Client;
use Sailfish\SfVeritas\Collectors\SailfishGuzzle;
$s3 = new S3Client([
'region' => 'us-east-1',
'version' => 'latest',
'http_handler' => \Aws\default_http_handler(),
'handler' => SailfishGuzzle::createStack(), // AWS SDK merges middleware from this stack
]);
For the AWS SDK specifically, you can also add the middleware directly to an existing client:
use Aws\S3\S3Client;
use Sailfish\SfVeritas\Collectors\OutboundHeaderInjector;
use Sailfish\SfVeritas\Collectors\NetworkRequestCollector;
$s3 = new S3Client([/* ... */]);
$s3->getHandlerList()->appendBuild(OutboundHeaderInjector::middleware(), 'sfveritas_headers');
$s3->getHandlerList()->appendBuild(NetworkRequestCollector::middleware(), 'sfveritas_capture');
Pattern C -- SDK accepts only a handler callable (no Guzzle at all):
Construct a Guzzle handler stack and pass its handler:
use GuzzleHttp\HandlerStack;
use Sailfish\SfVeritas\Collectors\SailfishGuzzle;
$stack = SailfishGuzzle::createStack();
$handler = $stack; // HandlerStack is itself callable
$sdk = new SomeSdk(['handler' => $handler]);
Check the SDK's client constructor. Most Guzzle-based PHP SDKs accept one of:
'client', 'http_client', 'guzzle', 'handler', 'http_handler', or a setHttpClient() method.
If you cannot inject a client, the SDK is likely using raw cURL -- in which case there is nothing to do at the SDK layer; headers will not propagate through it.
4. Symfony HttpClient (manually constructed)
If you build a Symfony HttpClient outside the DI container (e.g., HttpClient::create() in a script), wrap it with SailfishHttpClient:
use Symfony\Component\HttpClient\HttpClient;
use Sailfish\SfVeritas\Collectors\SailfishHttpClient;
$client = new SailfishHttpClient(HttpClient::create());
$response = $client->request('GET', 'https://api.example.com/users'); // ✅ instrumented
This works identically in Laravel, Symfony (for manually-built clients), and Standalone PHP.
5. curl_exec() / curl_multi_exec()
There is no way to monkey-patch curl_exec() at the PHP level. Every curl_exec() call site must be rewritten to CurlInterceptor::exec().
Single-handle:
use Sailfish\SfVeritas\Collectors\CurlInterceptor;
$ch = curl_init('https://api.example.com/users');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Accept: application/json']); // set your headers BEFORE exec
$result = CurlInterceptor::exec($ch); // ✅ instrumented (drop-in replacement for curl_exec)
curl_close($ch);
CurlInterceptor::exec()cURL does not expose previously-set headers, so CurlInterceptor cannot read and merge them. Set CURLOPT_HTTPHEADER before the call; CurlInterceptor will append the trace headers to whatever you set.
Multi-handle:
use Sailfish\SfVeritas\Collectors\CurlInterceptor;
$mh = curl_multi_init();
$handles = [];
foreach ($urls as $url) {
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_multi_add_handle($mh, $ch);
$handles[] = $ch;
}
CurlInterceptor::injectHeadersMulti($mh, $handles); // inject trace headers into all handles
$startTime = microtime(true);
do {
$status = curl_multi_exec($mh, $active);
curl_multi_select($mh);
} while ($active && $status === CURLM_OK);
CurlInterceptor::captureMultiResults($mh, $handles, $startTime); // capture telemetry
foreach ($handles as $ch) {
curl_multi_remove_handle($mh, $ch);
curl_close($ch);
}
curl_multi_close($mh);
grep -rn 'curl_exec\|curl_multi_exec' --include='*.php' src/
Each hit is a rewrite. If rewriting is impractical in legacy code, those requests will not carry trace headers -- distributed tracing will stop at the PHP boundary.
6. file_get_contents() HTTP
For HTTP URLs fetched via file_get_contents('https://...'), use HttpStreamInterceptor.
Step 1 -- enable the interceptor once at startup. Set the environment variable:
SF_INTERCEPT_FILE_GET_CONTENTS=true
Or programmatically:
use Sailfish\SfVeritas\Collectors\HttpStreamInterceptor;
HttpStreamInterceptor::enable();
Step 2 -- replace every file_get_contents() HTTP call site with the wrapper:
use Sailfish\SfVeritas\Collectors\HttpStreamInterceptor;
$data = HttpStreamInterceptor::fileGetContents('https://api.example.com/data'); // ✅ instrumented
// The wrapper is a drop-in replacement: non-HTTP URLs (file://, /local/path) pass through unchanged.
$config = HttpStreamInterceptor::fileGetContents(__DIR__ . '/config.json'); // falls through to file_get_contents()
PHP does not let us replace the http:// and https:// stream wrappers globally without disabling the built-in ones, which is too invasive. Every call site must be rewritten. Finding them:
grep -rn "file_get_contents\\s*(\\s*['\"]https\\?:" --include='*.php' src/
Skipping Specific Domains
To stop the SDK from adding the X-Sf3-Rid header to specific domains (for example, external APIs that reject unknown headers under strict CORS), pass them via domains_to_not_propagate_headers_to:
SetupInterceptors::init([
'api_key' => '<see-api-key-from-your-account-settings-page>',
'service_identifier' => 'acme-corp/my-service/bootstrap.php',
'domains_to_not_propagate_headers_to' => [
'api.stripe.com',
'*.amazonaws.com',
'api.twilio.com',
],
]);
Wildcards (*, ?) and subdomain suffix matching are supported. This list applies to all mechanisms above (Guzzle middleware, Symfony decorator, cURL interceptor, stream interceptor).
Installation Checklist
Before declaring instrumentation "done," audit your codebase for each mechanism:
- ☐ Framework HTTP client (
Http::*in Laravel / DIHttpClientInterfacein Symfony) -- automatic, nothing to do. - ☐
grep -rn 'new GuzzleHttp\\\\Client\\|new Client(' --include='*.php' src/→ each hit needsSailfishGuzzle::createClient()/SailfishGuzzle::wrapClient()or the DI binding above. - ☐ List every third-party SDK that makes HTTP calls (AWS, Stripe, Twilio, etc.) → pass an instrumented client/handler to each.
- ☐
grep -rn 'HttpClient::create' --include='*.php' src/→ wrap each withnew SailfishHttpClient(...). - ☐
grep -rn 'curl_exec\\|curl_multi_exec' --include='*.php' src/→ rewrite each toCurlInterceptor::exec()/CurlInterceptor::injectHeadersMulti(). - ☐
grep -rn "file_get_contents\\s*(\\s*['\\\"]https\\?:" --include='*.php' src/→ rewrite each toHttpStreamInterceptor::fileGetContents()and setSF_INTERCEPT_FILE_GET_CONTENTS=true.
GIT_SHA
The SDK auto-detects GIT_SHA from common CI/CD platforms:
| CI Platform | Environment Variable |
|---|---|
| GitHub Actions | GITHUB_SHA |
| GitLab CI | CI_COMMIT_SHA |
| CircleCI | CIRCLE_SHA1 |
| Jenkins | GIT_COMMIT |
| Bitbucket Pipelines | BITBUCKET_COMMIT |
| Travis CI | TRAVIS_COMMIT |
| Azure DevOps | BUILD_SOURCEVERSION |
| AWS CodeBuild | CODEBUILD_RESOLVED_SOURCE_VERSION |
| Vercel | VERCEL_GIT_COMMIT_SHA |
For custom CI systems, set it manually:
docker build --build-arg GIT_SHA=$(git rev-parse HEAD) .
Environment Variables
| Variable | Default | Description |
|---|---|---|
SAILFISH_API_KEY | -- | API key |
SAILFISH_SERVICE_IDENTIFIER | -- | Unique service name |
SAILFISH_SERVICE_VERSION | -- | Service version string |
SAILFISH_SERVICE_DISPLAY_NAME | -- | Display name in the UI |
SF_DEBUG | false | Enable debug logging |
SF_ENABLE_FUNCTION_SPANS | true | Enable function span capture |
SF_FUNCSPAN_CAPTURE_ARGUMENTS | true | Capture function arguments |
SF_FUNCSPAN_CAPTURE_RETURN_VALUE | true | Capture return values |
SF_FUNCSPAN_ARG_LIMIT_MB | 1 | Max argument capture size in MB |
SF_FUNCSPAN_RETURN_LIMIT_MB | 1 | Max return value capture size in MB |
SF_FUNCSPAN_SAMPLING_RATE | 1.0 | Function span sampling rate (0.0 to 1.0) |
SF_FUNCSPAN_AUTOCAPTURE_ALL_CHILD_FUNCTIONS | true | Auto-capture all child function calls |
SF_NETWORKHOP_CAPTURE_ENABLED | true | Enable inbound/outbound HTTP tracing |
SF_NETWORKHOP_CAPTURE_REQUEST_HEADERS | false | Capture HTTP request headers |
SF_NETWORKHOP_CAPTURE_REQUEST_BODY | false | Capture HTTP request bodies |
SF_NETWORKHOP_CAPTURE_RESPONSE_HEADERS | false | Capture HTTP response headers |
SF_NETWORKHOP_CAPTURE_RESPONSE_BODY | false | Capture HTTP response bodies |
Verifying the Setup
- Deploy your application with the Sailfish SDK configured
- Trigger some activity in your application
- Open the Sailfish dashboard
- You should see telemetry appearing in your project
Troubleshooting
No logs appearing
- Check the API key: Ensure
api_keyis set to your Enterprise API key - Check the service identifier: Ensure
service_identifieruses the<org>/<repo>/<path>format - Check terminal output: Look for Sailfish initialization messages
Connection errors
- Ensure your deployment can reach
https://api-service.sailfish.ai - Check that outbound HTTPS (port 443) is not blocked by a firewall or network policy
Import errors
- Ensure
sailfish/sf-veritasis installed:composer show sailfish/sf-veritas - Check you're using PHP 8.1+:
php -v - Verify the Sailfish repository is in your
composer.json - Run
composer dump-autoloadto regenerate the autoloader
Multi-Service Setup
When running multiple PHP services, give each a unique service_identifier:
// user-service/bootstrap.php
SetupInterceptors::init([
'api_key' => '<see-api-key-from-your-account-settings-page>',
'service_identifier' => 'acme-corp/user-service/bootstrap.php', // Format: <org>/<repo>/<path-to-this-file>
'service_version' => '1.0.0',
]);
// order-service/bootstrap.php
SetupInterceptors::init([
'api_key' => '<ApiKey />',
'service_identifier' => 'acme-corp/order-service/bootstrap.php', // Format: <org>/<repo>/<path-to-this-file>
'service_version' => '1.0.0',
]);
Next Steps
- Set up your frontend application (optional)
- Check the Sailfish dashboard to verify telemetry is flowing
Looking to set up SF Veritas for local development with the Desktop App? See the Desktop App PHP guide.