Skip to main content

Backend Setup: PHP

Enterprise Configuration

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

  1. Open the Sailfish dashboard
  2. Log in with your enterprise email
  3. Navigate to Settings > Configuration
  4. Copy your company's API key

Installation

1. Add the Sailfish Repository

Add the Sailfish Composer repository to your composer.json:

Add to your composer.json
{
"repositories": [
{
"type": "composer",
"url": "https://satis.sailfishqa.com/repo"
}
]
}

2. Install the Package

composer require sailfish/sf-veritas
Requirements
  • PHP 8.1 or higher
  • ext-curl extension (usually enabled by default)
  • ext-json extension (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

OptionTypeRequiredDescription
api_keystringYesYour Sailfish Enterprise API key
service_identifierstringYesUnique identifier in <org>/<repo>/<path> format
service_versionstringNoVersion of your service
service_display_namestringNoDisplay name in the UI
git_shastringNoGit commit SHA (auto-detected if not set)
git_orgstringNoGit organization (auto-detected if not set)
git_repostringNoGit repository name (auto-detected if not set)
domains_to_not_propagate_headers_toarrayNoDomains to skip header propagation
enable_log_captureboolNoEnable Monolog log capture (default: true)
enable_exception_captureboolNoEnable exception capture (default: true)
enable_print_captureboolNoEnable echo/print capture (default: true)
debugboolNoEnable 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, and var_dump output
  • 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 Http facade and Symfony's DI-injected HttpClientInterface. See the HTTP Client Instrumentation section -- other HTTP mechanisms (raw Guzzle, cURL, file_get_contents, third-party SDKs) require explicit wiring per client.
Outbound HTTP is NOT fully automatic

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...
Auto-Detection

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 MechanismLaravelSymfonyStandalone
1Framework-native HTTP client✅ Automatic✅ AutomaticN/A
2Direct Guzzle (new GuzzleHttp\Client())❌ Manual❌ Manual❌ Manual
3Third-party SDKs with embedded Guzzle (AWS, Stripe, Twilio, etc.)❌ Manual❌ Manual❌ Manual
4Symfony HttpClient (manually constructed)❌ Manual❌ Manual*❌ Manual
5curl_exec() / curl_multi_exec()❌ Manual❌ Manual❌ Manual
6file_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.

Does NOT cover
  • 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]);
How do I find the right option?

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);
Set your headers before calling 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);
Finding every call site
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()
Not a true hook

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:

  1. ☐ Framework HTTP client (Http::* in Laravel / DI HttpClientInterface in Symfony) -- automatic, nothing to do.
  2. grep -rn 'new GuzzleHttp\\\\Client\\|new Client(' --include='*.php' src/ → each hit needs SailfishGuzzle::createClient() / SailfishGuzzle::wrapClient() or the DI binding above.
  3. ☐ List every third-party SDK that makes HTTP calls (AWS, Stripe, Twilio, etc.) → pass an instrumented client/handler to each.
  4. grep -rn 'HttpClient::create' --include='*.php' src/ → wrap each with new SailfishHttpClient(...).
  5. grep -rn 'curl_exec\\|curl_multi_exec' --include='*.php' src/ → rewrite each to CurlInterceptor::exec() / CurlInterceptor::injectHeadersMulti().
  6. grep -rn "file_get_contents\\s*(\\s*['\\\"]https\\?:" --include='*.php' src/ → rewrite each to HttpStreamInterceptor::fileGetContents() and set SF_INTERCEPT_FILE_GET_CONTENTS=true.

GIT_SHA

The SDK auto-detects GIT_SHA from common CI/CD platforms:

CI PlatformEnvironment Variable
GitHub ActionsGITHUB_SHA
GitLab CICI_COMMIT_SHA
CircleCICIRCLE_SHA1
JenkinsGIT_COMMIT
Bitbucket PipelinesBITBUCKET_COMMIT
Travis CITRAVIS_COMMIT
Azure DevOpsBUILD_SOURCEVERSION
AWS CodeBuildCODEBUILD_RESOLVED_SOURCE_VERSION
VercelVERCEL_GIT_COMMIT_SHA

For custom CI systems, set it manually:

docker build --build-arg GIT_SHA=$(git rev-parse HEAD) .

Environment Variables

VariableDefaultDescription
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_DEBUGfalseEnable debug logging
SF_ENABLE_FUNCTION_SPANStrueEnable function span capture
SF_FUNCSPAN_CAPTURE_ARGUMENTStrueCapture function arguments
SF_FUNCSPAN_CAPTURE_RETURN_VALUEtrueCapture return values
SF_FUNCSPAN_ARG_LIMIT_MB1Max argument capture size in MB
SF_FUNCSPAN_RETURN_LIMIT_MB1Max return value capture size in MB
SF_FUNCSPAN_SAMPLING_RATE1.0Function span sampling rate (0.0 to 1.0)
SF_FUNCSPAN_AUTOCAPTURE_ALL_CHILD_FUNCTIONStrueAuto-capture all child function calls
SF_NETWORKHOP_CAPTURE_ENABLEDtrueEnable inbound/outbound HTTP tracing
SF_NETWORKHOP_CAPTURE_REQUEST_HEADERSfalseCapture HTTP request headers
SF_NETWORKHOP_CAPTURE_REQUEST_BODYfalseCapture HTTP request bodies
SF_NETWORKHOP_CAPTURE_RESPONSE_HEADERSfalseCapture HTTP response headers
SF_NETWORKHOP_CAPTURE_RESPONSE_BODYfalseCapture HTTP response bodies

Verifying the Setup

  1. Deploy your application with the Sailfish SDK configured
  2. Trigger some activity in your application
  3. Open the Sailfish dashboard
  4. You should see telemetry appearing in your project

Troubleshooting

No logs appearing

  1. Check the API key: Ensure api_key is set to your Enterprise API key
  2. Check the service identifier: Ensure service_identifier uses the <org>/<repo>/<path> format
  3. Check terminal output: Look for Sailfish initialization messages

Connection errors

  1. Ensure your deployment can reach https://api-service.sailfish.ai
  2. Check that outbound HTTPS (port 443) is not blocked by a firewall or network policy

Import errors

  1. Ensure sailfish/sf-veritas is installed: composer show sailfish/sf-veritas
  2. Check you're using PHP 8.1+: php -v
  3. Verify the Sailfish repository is in your composer.json
  4. Run composer dump-autoload to 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


Local Development

Looking to set up SF Veritas for local development with the Desktop App? See the Desktop App PHP guide.