Skip to content

Commit be76fb7

Browse files
authored
Fallback agent client (#2072)
1 parent 6f19d11 commit be76fb7

4 files changed

Lines changed: 743 additions & 27 deletions

File tree

src/Agent/Transport/AgentClient.php

Lines changed: 192 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
class AgentClient implements HttpClientInterface
1313
{
14+
private const SOCKET_TIMEOUT_SECONDS = 0.01;
15+
1416
/**
1517
* @var string
1618
*/
@@ -26,10 +28,34 @@ class AgentClient implements HttpClientInterface
2628
*/
2729
private $socket;
2830

29-
public function __construct(string $host = '127.0.0.1', int $port = 5148)
31+
/**
32+
* @var HttpClientInterface|null
33+
*/
34+
private $fallbackClient;
35+
36+
/**
37+
* @var (callable(): HttpClientInterface)|null
38+
*/
39+
private $fallbackClientFactory;
40+
41+
/**
42+
* @var string|null
43+
*/
44+
private $fallbackClientError;
45+
46+
/**
47+
* @var string
48+
*/
49+
private $lastSendError = '';
50+
51+
/**
52+
* @phpstan-param (callable(): HttpClientInterface)|null $fallbackClientFactory
53+
*/
54+
public function __construct(string $host = '127.0.0.1', int $port = 5148, ?callable $fallbackClientFactory = null)
3055
{
3156
$this->host = $host;
3257
$this->port = $port;
58+
$this->fallbackClientFactory = $fallbackClientFactory;
3359
}
3460

3561
public function __destruct()
@@ -46,16 +72,26 @@ private function connect(): bool
4672
return true;
4773
}
4874

49-
// We set the timeout to 10ms to avoid blocking the request for too long if the agent is not running
50-
// @TODO: 10ms should be low enough? Do we want to go lower and/or make this configurable? Only applies to initial connection.
51-
$socket = fsockopen($this->host, $this->port, $errorNo, $errorMsg, 0.01);
75+
// 10ms connect timeout to avoid blocking the request if the agent is not running
76+
$errorNo = 0;
77+
$errorMsg = '';
78+
$socket = @fsockopen($this->host, $this->port, $errorNo, $errorMsg, self::SOCKET_TIMEOUT_SECONDS);
5279

53-
// @TODO: Error handling? See $errorNo and $errorMsg
5480
if ($socket === false) {
81+
$this->lastSendError = \sprintf(
82+
'Failed to connect to the local Sentry agent at %s:%d. [%d] %s',
83+
$this->host,
84+
$this->port,
85+
$errorNo,
86+
$errorMsg
87+
);
88+
5589
return false;
5690
}
5791

58-
// @TODO: Set a timeout for the socket to prevent blocking (?) if the socket connection stops working after the connection (e.g. the agent is stopped) if needed
92+
// Use non-blocking writes with stream_select() so a hung agent cannot block the caller indefinitely.
93+
stream_set_blocking($socket, false);
94+
5995
$this->socket = $socket;
6096

6197
return true;
@@ -72,17 +108,120 @@ private function disconnect(): void
72108
$this->socket = null;
73109
}
74110

75-
private function send(string $message): void
111+
private function send(string $message): bool
76112
{
77-
if (!$this->connect()) {
78-
return;
113+
$this->lastSendError = '';
114+
115+
$payload = pack('N', \strlen($message) + 4) . $message;
116+
117+
// Attempt to send the payload, retrying once on write failure to handle
118+
// stale sockets (e.g. agent restarts in long-running workers).
119+
for ($attempt = 0; $attempt < 2; ++$attempt) {
120+
if (!$this->connect()) {
121+
return false;
122+
}
123+
124+
if ($this->writePayload($payload)) {
125+
return true;
126+
}
127+
128+
$this->disconnect();
129+
}
130+
131+
$this->lastSendError = \sprintf(
132+
'Failed to write envelope to the local Sentry agent at %s:%d.',
133+
$this->host,
134+
$this->port
135+
);
136+
137+
return false;
138+
}
139+
140+
private function writePayload(string $payload): bool
141+
{
142+
if ($this->socket === null) {
143+
return false;
144+
}
145+
146+
$socket = $this->socket;
147+
$payloadLength = \strlen($payload);
148+
$totalWrittenBytes = 0;
149+
$writeDeadline = microtime(true) + self::SOCKET_TIMEOUT_SECONDS;
150+
151+
while ($totalWrittenBytes < $payloadLength) {
152+
if (!$this->waitUntilSocketIsWritable($socket, $writeDeadline)) {
153+
return false;
154+
}
155+
156+
$bytesWritten = @fwrite($socket, (string) substr($payload, $totalWrittenBytes));
157+
158+
if ($bytesWritten === false) {
159+
return false;
160+
}
161+
162+
$totalWrittenBytes += $bytesWritten;
163+
}
164+
165+
return true;
166+
}
167+
168+
/**
169+
* @param resource $socket
170+
*/
171+
private function waitUntilSocketIsWritable($socket, float $deadline): bool
172+
{
173+
$remainingSeconds = $deadline - microtime(true);
174+
175+
if ($remainingSeconds <= 0) {
176+
return false;
177+
}
178+
179+
$readSockets = null;
180+
$writeSockets = [$socket];
181+
$exceptSockets = null;
182+
$selectedSockets = @stream_select(
183+
$readSockets,
184+
$writeSockets,
185+
$exceptSockets,
186+
0,
187+
(int) ceil($remainingSeconds * 1000000)
188+
);
189+
190+
return $selectedSockets !== false && $selectedSockets > 0;
191+
}
192+
193+
private function getFallbackClient(): ?HttpClientInterface
194+
{
195+
if ($this->fallbackClient !== null) {
196+
return $this->fallbackClient;
197+
}
198+
199+
if ($this->fallbackClientFactory === null) {
200+
return null;
79201
}
80202

81-
// @TODO: Make sure we don't send more than 2^32 - 1 bytes
82-
$contentLength = pack('N', \strlen($message) + 4);
203+
try {
204+
$fallbackClient = ($this->fallbackClientFactory)();
205+
} catch (\Throwable $exception) {
206+
$this->fallbackClientFactory = null;
207+
$this->fallbackClientError = \sprintf(
208+
'Failed to initialize fallback HTTP client. Reason: "%s". Fallback delivery has been disabled.',
209+
$exception->getMessage()
210+
);
211+
212+
return null;
213+
}
83214

84-
// @TODO: Error handling?
85-
fwrite($this->socket, $contentLength . $message);
215+
if (!$fallbackClient instanceof HttpClientInterface) {
216+
$this->fallbackClientFactory = null;
217+
$this->fallbackClientError = 'The fallback client factory did not return an instance of HttpClientInterface. Fallback delivery has been disabled.';
218+
219+
return null;
220+
}
221+
222+
$this->fallbackClient = $fallbackClient;
223+
224+
return $this->fallbackClient;
86225
}
87226

88227
public function sendRequest(Request $request, Options $options): Response
@@ -93,9 +232,46 @@ public function sendRequest(Request $request, Options $options): Response
93232
return new Response(400, [], 'Request body is empty');
94233
}
95234

96-
$this->send($body);
235+
if ($this->send($body)) {
236+
// Since we are sending async there is no feedback so we always return an empty response
237+
return new Response(202, [], '');
238+
}
239+
240+
$logContext = [
241+
'agent_host' => $this->host,
242+
'agent_port' => $this->port,
243+
];
244+
245+
if ($this->lastSendError !== '') {
246+
$logContext['error'] = $this->lastSendError;
247+
}
248+
249+
$options->getLoggerOrNullLogger()->debug('Failed to hand off envelope to local Sentry agent.', $logContext);
250+
251+
$fallbackClient = $this->getFallbackClient();
252+
if ($fallbackClient !== null) {
253+
$options->getLoggerOrNullLogger()->debug('Using fallback HTTP client because local Sentry agent handoff failed.', $logContext);
254+
255+
try {
256+
return $fallbackClient->sendRequest($request, $options);
257+
} catch (\Throwable $exception) {
258+
$options->getLoggerOrNullLogger()->debug(
259+
'Fallback HTTP client failed while sending envelope.',
260+
array_merge($logContext, ['exception' => $exception])
261+
);
262+
263+
return new Response(502, [], \sprintf(
264+
'Failed to send envelope using fallback HTTP client. Reason: "%s".',
265+
$exception->getMessage()
266+
));
267+
}
268+
}
269+
270+
if ($this->fallbackClientError !== null) {
271+
$options->getLoggerOrNullLogger()->debug($this->fallbackClientError, $logContext);
272+
$this->fallbackClientError = null;
273+
}
97274

98-
// Since we are sending async there is no feedback so we always return an empty response
99-
return new Response(202, [], '');
275+
return new Response(502, [], 'Failed to send envelope to the local Sentry agent and no fallback client is available.');
100276
}
101277
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Sentry\Agent\Transport;
6+
7+
use Sentry\Client;
8+
use Sentry\HttpClient\HttpClient;
9+
use Sentry\HttpClient\HttpClientInterface;
10+
11+
final class AgentClientBuilder
12+
{
13+
/**
14+
* @var string
15+
*/
16+
private $host = '127.0.0.1';
17+
18+
/**
19+
* @var int
20+
*/
21+
private $port = 5148;
22+
23+
/**
24+
* @var (callable(): HttpClientInterface)|null
25+
*/
26+
private $fallbackClientFactory;
27+
28+
/**
29+
* @var bool
30+
*/
31+
private $isFallbackClientDisabled = false;
32+
33+
/**
34+
* @var string
35+
*/
36+
private $sdkIdentifier = Client::SDK_IDENTIFIER;
37+
38+
/**
39+
* @var string
40+
*/
41+
private $sdkVersion = Client::SDK_VERSION;
42+
43+
public static function create(): self
44+
{
45+
return new self();
46+
}
47+
48+
public function setHost(string $host): self
49+
{
50+
$this->host = $host;
51+
52+
return $this;
53+
}
54+
55+
public function setPort(int $port): self
56+
{
57+
$this->port = $port;
58+
59+
return $this;
60+
}
61+
62+
public function disableFallbackClient(): self
63+
{
64+
$this->isFallbackClientDisabled = true;
65+
$this->fallbackClientFactory = null;
66+
67+
return $this;
68+
}
69+
70+
public function setFallbackClient(HttpClientInterface $fallbackClient): self
71+
{
72+
return $this->setFallbackClientFactory(static function () use ($fallbackClient): HttpClientInterface {
73+
return $fallbackClient;
74+
});
75+
}
76+
77+
/**
78+
* @phpstan-param callable(): HttpClientInterface $fallbackClientFactory
79+
*/
80+
public function setFallbackClientFactory(callable $fallbackClientFactory): self
81+
{
82+
$this->isFallbackClientDisabled = false;
83+
$this->fallbackClientFactory = $fallbackClientFactory;
84+
85+
return $this;
86+
}
87+
88+
public function setSdkIdentifier(string $sdkIdentifier): self
89+
{
90+
$this->sdkIdentifier = $sdkIdentifier;
91+
92+
return $this;
93+
}
94+
95+
public function setSdkVersion(string $sdkVersion): self
96+
{
97+
$this->sdkVersion = $sdkVersion;
98+
99+
return $this;
100+
}
101+
102+
public function getClient(): AgentClient
103+
{
104+
if ($this->isFallbackClientDisabled) {
105+
return new AgentClient($this->host, $this->port, null);
106+
}
107+
108+
if ($this->fallbackClientFactory !== null) {
109+
return new AgentClient($this->host, $this->port, $this->fallbackClientFactory);
110+
}
111+
112+
return new AgentClient($this->host, $this->port, $this->createDefaultFallbackClientFactory());
113+
}
114+
115+
/**
116+
* @return callable(): HttpClientInterface
117+
*/
118+
private function createDefaultFallbackClientFactory(): callable
119+
{
120+
$sdkIdentifier = $this->sdkIdentifier;
121+
$sdkVersion = $this->sdkVersion;
122+
123+
return static function () use ($sdkIdentifier, $sdkVersion): HttpClientInterface {
124+
return new HttpClient($sdkIdentifier, $sdkVersion);
125+
};
126+
}
127+
}

0 commit comments

Comments
 (0)