1111
1212class 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}
0 commit comments