Skip to content
Open
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
3 changes: 2 additions & 1 deletion src/Factory/Log/Exporter/OtlpGrpcLogExporterFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Hyperf\Contract\ConfigInterface;
use Hyperf\OpenTelemetry\Transport\SwooleGrpcTransportFactory;
use OpenTelemetry\Contrib\Otlp\ContentTypes;
use OpenTelemetry\Contrib\Otlp\LogsExporter;
use OpenTelemetry\SDK\Common\Export\TransportFactoryInterface;
use OpenTelemetry\SDK\Logs\LogRecordExporterInterface;
Expand All @@ -28,7 +29,7 @@ public function make(): LogRecordExporterInterface
return new LogsExporter(
(new SwooleGrpcTransportFactory())->create(
endpoint: $endpoint,
contentType: 'application/x-protobuf',
contentType: ContentTypes::PROTOBUF,
headers: $options['headers'] ?? [],
compression: $options['compression'] ?? TransportFactoryInterface::COMPRESSION_GZIP,
timeout: $options['timeout'] ?? 10,
Expand Down
5 changes: 3 additions & 2 deletions src/Factory/Metric/Exporter/OtlpGrpcMetricExporterFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Hyperf\Contract\ConfigInterface;
use Hyperf\OpenTelemetry\Transport\SwooleGrpcTransportFactory;
use OpenTelemetry\Contrib\Otlp\ContentTypes;
use OpenTelemetry\Contrib\Otlp\MetricExporter;
use OpenTelemetry\SDK\Common\Export\TransportFactoryInterface;
use OpenTelemetry\SDK\Metrics\Data\Temporality;
Expand All @@ -24,12 +25,12 @@ public function make(): MetricExporterInterface
{
$options = $this->config->get('open-telemetry.metrics.exporters.otlp_grpc.options', []);

$endpoint = rtrim($options['endpoint'], '/') . self::GRPC_METHOD;
$endpoint = rtrim($options['endpoint'] ?? 'http://localhost:4317', '/') . self::GRPC_METHOD;

return new MetricExporter(
transport: (new SwooleGrpcTransportFactory())->create(
endpoint: $endpoint,
contentType: 'application/x-protobuf',
contentType: ContentTypes::PROTOBUF,
headers: $options['headers'] ?? [],
compression: $options['compression'] ?? TransportFactoryInterface::COMPRESSION_GZIP,
timeout: $options['timeout'] ?? 10,
Expand Down
5 changes: 3 additions & 2 deletions src/Factory/Trace/Exporter/OtlpGrpcTraceExporterFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Hyperf\Contract\ConfigInterface;
use Hyperf\OpenTelemetry\Transport\SwooleGrpcTransportFactory;
use OpenTelemetry\Contrib\Otlp\ContentTypes;
use OpenTelemetry\Contrib\Otlp\SpanExporter;
use OpenTelemetry\SDK\Common\Export\TransportFactoryInterface;
use OpenTelemetry\SDK\Trace\SpanExporterInterface;
Expand All @@ -23,12 +24,12 @@ public function make(): SpanExporterInterface
{
$options = $this->config->get('open-telemetry.traces.exporters.otlp_grpc.options', []);

$endpoint = rtrim($options['endpoint'], '/') . self::GRPC_METHOD;
$endpoint = rtrim($options['endpoint'] ?? 'http://localhost:4317', '/') . self::GRPC_METHOD;

return new SpanExporter(
(new SwooleGrpcTransportFactory())->create(
endpoint: $endpoint,
contentType: 'application/x-protobuf',
contentType: ContentTypes::PROTOBUF,
headers: $options['headers'] ?? [],
compression: $options['compression'] ?? TransportFactoryInterface::COMPRESSION_GZIP,
timeout: $options['timeout'] ?? 10,
Expand Down
4 changes: 4 additions & 0 deletions src/Listener/OtelShutdownListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

namespace Hyperf\OpenTelemetry\Listener;

use Hyperf\Command\Event\AfterExecute;
use Hyperf\Command\Event\AfterHandle;
use Hyperf\Contract\StdoutLoggerInterface;
use Hyperf\Coroutine\Coroutine;
use Hyperf\Event\Contract\ListenerInterface;
Expand All @@ -25,6 +27,8 @@ public function listen(): array
{
return [
OnWorkerExit::class,
AfterHandle::class,
AfterExecute::class,
];
Comment on lines 28 to 32

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

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

Subscribing to both AfterHandle and AfterExecute means process() will run twice for a single CLI command in environments where both events are dispatched, causing duplicate shutdown attempts. Consider guarding so shutdown runs only once (e.g., track a local shutdownCalled flag) or subscribe to only one of these events if possible.

Copilot uses AI. Check for mistakes.
}

Expand Down
26 changes: 13 additions & 13 deletions src/SpanProcessor/ChannelBatchSpanProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,19 +49,6 @@ public function __construct(
) {
}

private function ensureInitialized(): void
{
if ($this->initialized) {
return;
}

$this->initialized = true;
$this->exportContext = Context::getCurrent();
$this->channel = new Channel($this->channelCapacity);
$this->startConsumer();
$this->startFlushTimer();
}

public function onStart(ReadWriteSpanInterface $span, ContextInterface $parentContext): void
{
}
Expand Down Expand Up @@ -116,6 +103,19 @@ public function shutdown(?CancellationInterface $cancellation = null): bool
return $this->exporter->shutdown($cancellation);
}

private function ensureInitialized(): void
{
if ($this->initialized) {
return;
}

$this->initialized = true;
$this->exportContext = Context::getCurrent();
$this->channel = new Channel($this->channelCapacity);
$this->startConsumer();
$this->startFlushTimer();
}

private function pushBatch(): void
{
if ($this->batch === [] || $this->channel === null) {
Expand Down
166 changes: 130 additions & 36 deletions src/Transport/SwooleGrpcTransport.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
use OpenTelemetry\SDK\Common\Future\ErrorFuture;
use OpenTelemetry\SDK\Common\Future\FutureInterface;
use RuntimeException;
use Swoole\Coroutine;
use Swoole\Coroutine\Channel;
use Swoole\Coroutine\Http2\Client;
use Swoole\Http2\Request;
use Throwable;
Expand All @@ -22,6 +24,8 @@ final class SwooleGrpcTransport implements TransportInterface

private ?Client $client = null;

private ?Channel $mutex = null;

/**
* @SuppressWarnings(PHPMD.BooleanArgumentFlag)
*/
Expand All @@ -33,6 +37,8 @@ public function __construct(
private readonly float $timeout = 10.0,
private readonly bool $ssl = false,
private readonly ?string $compression = null,
private readonly int $retryDelay = 100,
private readonly int $maxRetries = 3,
) {
}

Expand All @@ -47,42 +53,21 @@ public function send(string $payload, ?CancellationInterface $cancellation = nul
return new ErrorFuture(new RuntimeException('Transport is closed'));
}

try {
$client = $this->getClient();

$data = $this->compress($payload);

$request = new Request();
$request->method = 'POST';
$request->path = $this->method;
$request->headers = $this->buildHeaders();
$request->data = $this->packMessage($data);

$streamId = $client->send($request);

if ($streamId === false || $streamId <= 0) {
return new ErrorFuture(new RuntimeException(
'Failed to send gRPC request: ' . ($client->errMsg ?: 'unknown error')
));
}

$response = $client->recv($this->timeout);

if ($response === false) {
return new ErrorFuture(new RuntimeException(
'Failed to receive gRPC response: ' . ($client->errMsg ?: 'timeout')
));
}
$mutex = $this->getMutex();
if ($mutex->pop() === false) {
return new ErrorFuture(new RuntimeException('Transport is closed'));
}

$grpcStatus = $response->headers['grpc-status'] ?? '0';
if ($grpcStatus !== '0') {
$grpcMessage = $response->headers['grpc-message'] ?? 'Unknown error';
return new ErrorFuture(new RuntimeException("gRPC error: {$grpcMessage}", (int) $grpcStatus));
try {
if ($this->closed) {
return new ErrorFuture(new RuntimeException('Transport is closed'));
}

return new CompletedFuture(null);
return $this->attemptSend($payload);
} catch (Throwable $e) {
return new ErrorFuture($e);
} finally {
$mutex->push(true); // release — returns false silently if channel was closed by shutdown
}
}

Expand All @@ -93,6 +78,7 @@ public function shutdown(?CancellationInterface $cancellation = null): bool
}

$this->closed = true;
$this->mutex?->close(); // unblock any coroutines waiting to acquire the mutex

if ($this->client !== null) {
$this->client->close();
Expand All @@ -107,19 +93,126 @@ public function forceFlush(?CancellationInterface $cancellation = null): bool
return ! $this->closed;
}

/**
* @SuppressWarnings(PHPMD.ErrorControlOperator)
*/
private function attemptSend(string $payload): FutureInterface
{
$data = $this->compress($payload);
$lastError = null;

for ($attempt = 0; $attempt <= $this->maxRetries; ++$attempt) {
if ($this->closed) {
return new ErrorFuture(new RuntimeException('Transport is closed'));
}

Comment on lines +104 to +108

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

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

The retry loop condition for ($attempt = 0; $attempt <= $this->maxRetries; ++$attempt) will execute zero times if maxRetries is negative, resulting in an immediate Unknown transport error after retries without any send attempt. Consider normalizing maxRetries to >= 0 (e.g., in the constructor) or throwing on invalid values to avoid silent misconfiguration.

Copilot uses AI. Check for mistakes.
if ($attempt > 0 && $this->retryDelay > 0) {
Coroutine::sleep($this->retryDelay / 1000.0);
}

try {
return $this->doRequest($this->getClient(), $data);
} catch (Throwable $e) {
$this->resetClient();
$lastError = $e;
}
}

return new ErrorFuture($lastError ?? new RuntimeException('Unknown transport error after retries'));
}

/**
* Executes a single gRPC send+recv cycle.
*
* Throws RuntimeException on send failure so the caller can reset the client and retry.
* Returns ErrorFuture on recv failure (delivery uncertain — no retry).
*
* Note: @$client->recv() suppresses E_DEPRECATED from Swoole setting $serverLastStreamId
* as a dynamic property in PHP 8.2+. Hyperf's ErrorExceptionHandler would otherwise convert
* the deprecation notice into an ErrorException, aborting the recv() call.
*
* @throws RuntimeException on retryable send failure
* @SuppressWarnings(PHPMD.ErrorControlOperator)
*/
private function doRequest(Client $client, string $data): FutureInterface
{
$request = new Request();
$request->method = 'POST';
$request->path = $this->method;
$request->headers = $this->buildHeaders();
$request->data = $this->packMessage($data);

$streamId = $client->send($request);

if ($streamId === false || $streamId <= 0) {
// Throw so the retry loop can reset the client and try again;
// data was never transmitted so retrying is safe.
throw new RuntimeException(
'Failed to send gRPC request: ' . ($client->errMsg ?: 'unknown error')
);
}

$response = @$client->recv($this->timeout);

if ($response === false) {
$this->resetClient($client);
// Do not retry: send succeeded, so delivery is uncertain — retrying could duplicate exports.
return new ErrorFuture(new RuntimeException(
'Failed to receive gRPC response: ' . ($client->errMsg ?: 'timeout')
));
}

$grpcStatus = (int) ($response->headers['grpc-status'] ?? 0);
if ($grpcStatus !== 0) {
$grpcMessage = $response->headers['grpc-message'] ?? 'Unknown error';
// gRPC application error — not retried
return new ErrorFuture(new RuntimeException("gRPC error: {$grpcMessage}", $grpcStatus));
}

return new CompletedFuture(null);
}

private function getMutex(): Channel
{
if ($this->mutex === null) {
$this->mutex = new Channel(1);
$this->mutex->push(true); // initially unlocked
}

return $this->mutex;
}

private function resetClient(?Client $client = null): void
{
$target = $client ?? $this->client;
$target?->close();
if ($target === $this->client) {
$this->client = null;
}
}

private function getClient(): Client
{
if ($this->client === null || ! $this->client->connected) {
$this->client = new Client($this->host, $this->port, $this->ssl);
$this->client->set([
if ($this->client !== null && ! $this->client->connected) {
$this->client->close();
$this->client = null;
}

if ($this->client === null) {
$client = new Client($this->host, $this->port, $this->ssl);
$client->set([
'timeout' => $this->timeout,
]);

if (! $this->client->connect()) {
if (! $client->connect()) {
$errMsg = $client->errMsg;
$client->close();
throw new RuntimeException(
"Failed to connect to {$this->host}:{$this->port}: " . $this->client->errMsg
"Failed to connect to {$this->host}:{$this->port}: " . $errMsg
);
}

$this->client = $client;
}

return $this->client;
Expand Down Expand Up @@ -165,6 +258,7 @@ private function buildHeaders(): array
private function packMessage(string $data): string
{
$compressed = $this->compression !== null ? 1 : 0;
// gRPC message frame: [1-byte compressed flag][4-byte big-endian message length][message]
return pack('CN', $compressed, strlen($data)) . $data;
}
}
16 changes: 16 additions & 0 deletions src/Transport/SwooleGrpcTransportFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@

final class SwooleGrpcTransportFactory implements TransportFactoryInterface
{
/**
* Note: $cacert, $cert, and $key are accepted to satisfy the TransportFactoryInterface
* contract but are not implemented in this transport. TLS certificates are not supported;
* use the https:// or grpcs:// scheme for basic TLS.
*
* @param null|mixed $compression
*/
public function create(
string $endpoint,
string $contentType = ContentTypes::PROTOBUF,
Expand Down Expand Up @@ -45,6 +52,13 @@ public function create(
$compressionType = null;
}

$supportedCompression = [null, TransportFactoryInterface::COMPRESSION_GZIP, TransportFactoryInterface::COMPRESSION_DEFLATE];
if (! in_array($compressionType, $supportedCompression, true)) {
throw new InvalidArgumentException(
sprintf('Unsupported compression type "%s"', $compressionType)
);
}

return new SwooleGrpcTransport(
host: $parsed['host'],
port: $parsed['port'],
Expand All @@ -53,6 +67,8 @@ public function create(
timeout: $timeout,
ssl: $parsed['ssl'],
compression: $compressionType,
retryDelay: $retryDelay,
maxRetries: $maxRetries,
Comment on lines 67 to +71

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

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

retryDelay/maxRetries are now forwarded into the transport, but there’s no validation that they’re non-negative. A negative maxRetries will cause the retry loop to never run (no send attempt), and a negative retryDelay could lead to confusing behavior. Consider validating (and rejecting) negative values here before constructing the transport.

Copilot uses AI. Check for mistakes.
);
}

Expand Down
Loading