Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

declare(strict_types=1);

namespace OpenTelemetry\Contrib\Instrumentation\Laravel\Hooks\Illuminate\Http\Client;

use Illuminate\Http\Client\PendingRequest as IlluminatePendingRequest;
use OpenTelemetry\API\Trace\Propagation\TraceContextPropagator;
use OpenTelemetry\Context\Context;
use OpenTelemetry\Contrib\Instrumentation\Laravel\Hooks\LaravelHook;
use OpenTelemetry\Contrib\Instrumentation\Laravel\Hooks\LaravelHookTrait;
use OpenTelemetry\Contrib\Instrumentation\Laravel\LaravelInstrumentation;
use OpenTelemetry\Contrib\Instrumentation\Laravel\Propagators\RequestPropagationSetter;
use function OpenTelemetry\Instrumentation\hook;
use Psr\Http\Message\RequestInterface;

class PendingRequest implements LaravelHook
{
use LaravelHookTrait;

public function instrument(): void
{
$this->hookRunBeforeSendingCallbacks();
}

/**
* `runBeforeSendingCallbacks()` is where Laravel finalizes the PSR-7 request just before
* handing it to Guzzle, so injecting into its first argument here reaches the request that
* is actually sent (the `RequestSending` event ClientRequestWatcher listens on can't do this
* itself, since it only receives an immutable copy that never reaches the real request).
*
* Gated behind an opt-in flag: the target of an outbound HTTP client request may be a
* third-party service outside the application's control, which should not receive internal
* trace (and, in future, baggage) data unless the developer explicitly asks for it.
*
* @psalm-suppress PossiblyUnusedReturnValue
*/
protected function hookRunBeforeSendingCallbacks(): bool
{
return hook(
IlluminatePendingRequest::class,
'runBeforeSendingCallbacks',
pre: function (IlluminatePendingRequest $_pendingRequest, array $params) {
if (!LaravelInstrumentation::shouldPropagateHttpClientTraceContext()) {
return $params;
}

$request = $params[0];
if (!$request instanceof RequestInterface) {
return $params;
}

/** @phan-suppress-next-line PhanAccessMethodInternal */
TraceContextPropagator::getInstance()->inject($request, RequestPropagationSetter::instance(), Context::getCurrent());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This call currently fails the Laravel quality jobs with PhanAccessMethodInternal: RequestPropagationSetter is marked @internal and is being called from the Hooks subnamespace. Please adjust the annotation/scope or use the project’s established suppression pattern so the required quality checks pass.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@PuvaanRaaj

Suppressed via the same @phan-suppress-next-line PhanAccessMethodInternal pattern used in Kernel.php, rather than removing @internal from

▎ RequestPropagationSetter (it's intentionally internal). phan passes locally now.

fix commit

$params[0] = $request;

return $params;
},
);
}
}
12 changes: 12 additions & 0 deletions src/Instrumentation/Laravel/src/LaravelInstrumentation.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public static function register(): void
Hooks\Illuminate\Contracts\Queue\Queue::hook($instrumentation);
Hooks\Illuminate\Foundation\Application::hook($instrumentation);
Hooks\Illuminate\Foundation\Console\ServeCommand::hook($instrumentation);
Hooks\Illuminate\Http\Client\PendingRequest::hook($instrumentation);
Hooks\Illuminate\Queue\SyncQueue::hook($instrumentation);
Hooks\Illuminate\Queue\Queue::hook($instrumentation);
Hooks\Illuminate\Queue\Worker::hook($instrumentation);
Expand All @@ -40,4 +41,15 @@ class_exists(Configuration::class)
&& Configuration::getBoolean('OTEL_PHP_TRACE_CLI_ENABLED', false)
);
}

/**
* Trace context (and, in future, baggage) is only injected into outbound HTTP client requests
* when explicitly opted into, since the target of those requests may be a third-party service
* outside the application's control that should not receive internal trace/baggage data.
*/
public static function shouldPropagateHttpClientTraceContext(): bool
{
return class_exists(Configuration::class)
&& Configuration::getBoolean('OTEL_PHP_INSTRUMENTATION_LARAVEL_HTTP_CLIENT_PROPAGATION_ENABLED', false);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

namespace OpenTelemetry\Contrib\Instrumentation\Laravel\Propagators;

use function assert;
use OpenTelemetry\Context\Propagation\PropagationSetterInterface;
use Psr\Http\Message\RequestInterface;

/**
* @internal
*/
class RequestPropagationSetter implements PropagationSetterInterface
{
public static function instance(): self
{
static $instance;

return $instance ??= new self();
}

public function set(&$carrier, string $key, string $value): void
{
assert($carrier instanceof RequestInterface);

$carrier = $carrier->withHeader($key, $value);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,57 @@ public function test_it_records_requests(): void
self::assertEquals(StatusCode::STATUS_UNSET, $span->getStatus()->getCode());
}

public function test_it_does_not_inject_trace_context_by_default(): void
{
Http::fake();

$parent = $this->tracerProvider->getTracer('test')->spanBuilder('parent')->startSpan();
$scope = $parent->activate();

try {
Http::get('http://propagation.opentelemetry.io');
} finally {
$scope->detach();
$parent->end();
}

// The target of an outbound HTTP client request may be a third-party service outside the
// application's control, so propagation must stay off unless explicitly opted into.
Http::assertSent(static fn (Request $request): bool => !$request->hasHeader('traceparent'));
}

public function test_it_injects_trace_context_into_outbound_requests_when_opted_in(): void
{
putenv('OTEL_PHP_INSTRUMENTATION_LARAVEL_HTTP_CLIENT_PROPAGATION_ENABLED=true');

try {
Http::fake();

// A span must be active for there to be any trace context to propagate downstream,
// just as there would be one from the incoming request/command/job being traced.
$parent = $this->tracerProvider->getTracer('test')->spanBuilder('parent')->startSpan();
$scope = $parent->activate();

try {
Http::get('http://propagation.opentelemetry.io');
} finally {
$scope->detach();
$parent->end();
}

// Asserted separately from the header check below so a failure here points at the request
// never reaching PendingRequest::runBeforeSendingCallbacks(), rather than at the injected
// value being wrong.
Http::assertSentCount(1);

$traceparent = sprintf('00-%s-%s-01', $parent->getContext()->getTraceId(), $parent->getContext()->getSpanId());

Http::assertSent(static fn (Request $request): bool => $request->header('traceparent') === [$traceparent]);
} finally {
putenv('OTEL_PHP_INSTRUMENTATION_LARAVEL_HTTP_CLIENT_PROPAGATION_ENABLED');
}
}

public function test_it_records_connection_failures(): void
{
Http::fake(fn (Request $request) => new RejectedPromise(new ConnectException('Failure', $request->toPsrRequest())));
Expand Down
Loading