Manual instrumentation gives you fine-grained control when automatic instrumentation alone cannot express important business operations. Use it to capture steps that matter for debugging, attach business-specific attributes, or ensure failures surface with the right context in SigNoz.
Prerequisites
- PHP 8.1+.
- OpenTelemetry SDK installed via Composer:
composer require open-telemetry/sdk open-telemetry/exporter-otlp - Environment variables configured to export traces to SigNoz. See the PHP instrumentation guide for setup instructions.
Step 1. Get a tracer
Acquire a tracer from the global tracer provider to create spans:
<?php
use OpenTelemetry\API\Globals;
$tracerProvider = Globals::tracerProvider();
$tracer = $tracerProvider->getTracer(
'my-app', // instrumentation scope name
'1.0.0' // version (optional)
);Reuse the tracer instance throughout your application instead of creating new ones for each request.
Step 2. Create manual spans
Wrap important operations inside custom spans:
<?php
use OpenTelemetry\API\Globals;
function processOrder(string $orderId): void
{
$tracer = Globals::tracerProvider()->getTracer('order-service');
$span = $tracer->spanBuilder('process-order')->startSpan();
try {
// Your business logic here
validateOrder($orderId);
chargePayment($orderId);
fulfillOrder($orderId);
} finally {
$span->end();
}
}Tips:
- Use descriptive span names that match business operations (
checkout,validate-user,send-email). - Always call
end()on spans—they won't export otherwise. - Use
try/finallyto ensure spans end even when exceptions occur.
Step 3. Create nested spans
Track hierarchical operations by activating parent spans:
<?php
use OpenTelemetry\API\Globals;
use OpenTelemetry\API\Trace\Span;
function processCheckout(string $cartId): void
{
$tracer = Globals::tracerProvider()->getTracer('checkout-service');
// Parent span
$parentSpan = $tracer->spanBuilder('checkout')->startSpan();
$scope = $parentSpan->activate();
try {
// Child span - automatically linked to parent
$childSpan = $tracer->spanBuilder('validate-cart')->startSpan();
try {
validateCart($cartId);
} finally {
$childSpan->end();
}
// Another child span
$paymentSpan = $tracer->spanBuilder('process-payment')->startSpan();
try {
processPayment($cartId);
} finally {
$paymentSpan->end();
}
} finally {
$scope->detach();
$parentSpan->end();
}
}Always detach scopes to prevent context leaks.
Step 4. Propagate context
PHP keeps the active span in a context store the runtime manages. $span->activate() puts your span there and hands back a Scope you have to detach. Two things break that chain: a scope you forget to detach, and another service. See Context Propagation for the concepts behind this step.
Inside your process
A span becomes the parent of later spans only after you activate it. Detaching the scope restores whatever was current before:
<?php
use OpenTelemetry\API\Trace\Span;
$parent = $tracer->spanBuilder('process-order')->startSpan();
$scope = $parent->activate();
try {
// Child of process-order, because process-order is active
$child = $tracer->spanBuilder('validate-cart')->startSpan();
$child->end();
// Read the active span anywhere below this point
Span::getCurrent()->setAttribute('order.id', $orderId);
} finally {
$scope->detach();
$parent->end();
}
// Back to the previous context. A span started here is NOT a child of process-order.
$sibling = $tracer->spanBuilder('audit-order')->startSpan();
$sibling->end();Skip the detach() and every later span in the request attaches to a span that already ended.
Across a service boundary
Auto-instrumentation handles the frameworks and clients it covers. Write the calls yourself for any hop it does not.
<?php
use OpenTelemetry\API\Globals;
// Outgoing: inject writes traceparent into the carrier
function chargeCard(array $payload): void
{
$tracer = Globals::tracerProvider()->getTracer('checkout-service');
$span = $tracer->spanBuilder('charge-card')->startSpan();
$scope = $span->activate();
try {
$carrier = [];
Globals::propagator()->inject($carrier);
// $carrier is now ['traceparent' => '00-<trace-id>-<span-id>-01']
$client->post('https://payments.internal/charge', [
'headers' => $carrier,
'json' => $payload,
]);
} finally {
$scope->detach();
$span->end();
}
}On the receiving side, extract and pass the result to setParent:
<?php
use OpenTelemetry\API\Globals;
use OpenTelemetry\API\Trace\SpanKind;
function handleCharge(ServerRequestInterface $request): void
{
$tracer = Globals::tracerProvider()->getTracer('payment-service');
$context = Globals::propagator()->extract($request->getHeaders());
$span = $tracer->spanBuilder('handle-charge')
->setParent($context)
->setSpanKind(SpanKind::KIND_SERVER)
->startSpan();
$scope = $span->activate();
try {
// handle-charge is now a child of charge-card in the caller's trace
capturePayment($request);
} finally {
$scope->detach();
$span->end();
}
}Step 5. Add attributes
Attributes show up as key-value pairs in SigNoz for filtering and aggregation:
<?php
use OpenTelemetry\API\Globals;
function handlePayment(float $amount, string $currency): void
{
$tracer = Globals::tracerProvider()->getTracer('payment-service');
$span = $tracer->spanBuilder('payment')->startSpan();
try {
$span->setAttribute('payment.amount', $amount);
$span->setAttribute('payment.currency', $currency);
$span->setAttribute('payment.method', 'credit_card');
$span->setAttribute('user.id', getCurrentUserId());
// Process payment...
} finally {
$span->end();
}
}Use semantic conventions for common attributes when possible.
Step 6. Add events
Events capture notable moments within a span:
<?php
use OpenTelemetry\API\Globals;
use OpenTelemetry\SDK\Common\Attribute\Attributes;
function processJob(string $jobId): void
{
$tracer = Globals::tracerProvider()->getTracer('job-service');
$span = $tracer->spanBuilder('process-job')->startSpan();
try {
$span->addEvent('job.started');
// Do work...
$span->addEvent('job.completed', Attributes::create([
'job.id' => $jobId,
'job.result' => 'success',
'job.duration_ms' => 1234,
]));
} finally {
$span->end();
}
}Use events to mark retries, cache hits/misses, queue waits, and similar milestones.
Step 7. Record errors
Flag failures so they're easy to query in SigNoz:
<?php
use OpenTelemetry\API\Globals;
use OpenTelemetry\API\Trace\StatusCode;
function riskyOperation(): void
{
$tracer = Globals::tracerProvider()->getTracer('my-service');
$span = $tracer->spanBuilder('risky-operation')->startSpan();
try {
doSomethingRisky();
$span->setStatus(StatusCode::STATUS_OK);
} catch (Throwable $e) {
$span->recordException($e, [
'exception.escaped' => true,
]);
$span->setStatus(StatusCode::STATUS_ERROR, $e->getMessage());
throw $e;
} finally {
$span->end();
}
}recordException()attaches exception details including stack trace.- Setting status to
STATUS_ERRORsurfaces the span in SigNoz error views and alerts.
Step 8. Add span links
Link spans representing causally-related but not parent-child operations:
<?php
use OpenTelemetry\API\Globals;
function processBatch(array $itemSpanContexts): void
{
$tracer = Globals::tracerProvider()->getTracer('batch-service');
$spanBuilder = $tracer->spanBuilder('batch-process');
// Link to all items being processed in this batch
foreach ($itemSpanContexts as $context) {
$spanBuilder->addLink($context);
}
$span = $spanBuilder->startSpan();
try {
// Process batch...
} finally {
$span->end();
}
}Validate
- Trigger the code paths that emit manual spans.
- In SigNoz Traces, filter by
service.nameor your span name. - Open a trace and verify attributes, events, and error status.
- Check the Errors view to confirm failures show up with recorded exceptions.
Troubleshooting
Still not seeing data in SigNoz? Work through Debug missing traces, logs, and metrics, which covers SDK diagnostics, Collector connectivity, and the common ingestion errors for all three signals.
Why don't I see my custom spans in SigNoz?
- Ensure the OpenTelemetry extension is loaded:
php --ri opentelemetry - Verify environment variables are set correctly (
OTEL_PHP_AUTOLOAD_ENABLED=true,OTEL_EXPORTER_OTLP_ENDPOINT, etc.). - Check that
span->end()is being called—spans withoutend()won't export. - Make sure that the SDK is properly initialized before your application code runs.
Why are child spans not linked to parent spans?
- Make sure you call
activate()on the parent span before creating children. - Always
detach()the scope when done to prevent context leaks. - Verify you're using the same tracer provider instance throughout.
Why does the downstream service start its own trace?
- Make sure that the caller runs
injectand that the outgoing request carries atraceparentheader. - Make sure that the receiver calls
setParent($context)on the span builder. Without it the entry span takes whatever is current and starts a new trace. - Make sure that both services register the same propagator format. See Context Propagation.
Why don't attributes or events appear on the span?
- Call
setAttribute()oraddEvent()beforespan->end(). Post-end mutations are ignored. - Attribute values must be scalar types (string, int, float, bool) or arrays of scalars.
- Avoid reusing finished spans—always create a new span for each invocation.
Next steps
- Set up alerts for your PHP application
- Create dashboards to visualize your custom spans
- Explore supported PHP instrumentation libraries for automatic instrumentation of frameworks and libraries
- Read Context Propagation for the wire format and the failure modes shared across languages