1
- import binascii
2
1
import dataclasses
3
2
import io
4
3
import json
7
6
import os
8
7
import pathlib
9
8
import shutil
10
- from typing import Dict
9
+ import time
11
10
from typing import Optional
12
- from typing import Tuple
13
11
import zipfile
14
12
15
13
from ddtrace ._logger import _add_file_handler
@@ -32,6 +30,7 @@ class FlareSendRequest:
32
30
case_id : str
33
31
hostname : str
34
32
email : str
33
+ uuid : str # UUID from AGENT_TASK config for race condition prevention
35
34
source : str = "tracer_python"
36
35
37
36
@@ -55,6 +54,8 @@ def __init__(
55
54
self .url : str = trace_agent_url
56
55
self ._api_key : Optional [str ] = api_key
57
56
self .ddconfig = ddconfig
57
+ # Use a fixed boundary for consistency
58
+ self ._BOUNDARY = "83CAD6AA-8A24-462C-8B3D-FF9CC683B51B"
58
59
59
60
def prepare (self , log_level : str ):
60
61
"""
@@ -64,32 +65,15 @@ def prepare(self, log_level: str):
64
65
try :
65
66
self .flare_dir .mkdir (exist_ok = True )
66
67
except Exception as e :
67
- log .error ("Failed to create %s directory: %s" , self .flare_dir , e )
68
+ log .error ("Flare prepare: failed to create %s directory: %s" , self .flare_dir , e )
68
69
return
69
70
70
71
flare_log_level_int = logging .getLevelName (log_level )
71
72
if type (flare_log_level_int ) != int :
72
- raise TypeError ("Invalid log level provided: %s" , log_level )
73
+ raise TypeError ("Flare prepare: Invalid log level provided: %s" , log_level )
73
74
74
- ddlogger = get_logger ("ddtrace" )
75
- pid = os .getpid ()
76
- flare_file_path = self .flare_dir / f"tracer_python_{ pid } .log"
77
- self .original_log_level = ddlogger .level
78
-
79
- # Set the logger level to the more verbose between original and flare
80
- # We do this valid_original_level check because if the log level is NOTSET, the value is 0
81
- # which is the minimum value. In this case, we just want to use the flare level, but still
82
- # retain the original state as NOTSET/0
83
- valid_original_level = (
84
- logging .CRITICAL if self .original_log_level == logging .NOTSET else self .original_log_level
85
- )
86
- logger_level = min (valid_original_level , flare_log_level_int )
87
- ddlogger .setLevel (logger_level )
88
- self .file_handler = _add_file_handler (
89
- ddlogger , flare_file_path .__str__ (), flare_log_level_int , TRACER_FLARE_FILE_HANDLER_NAME
90
- )
91
-
92
- # Create and add config file
75
+ # Setup logging and create config file
76
+ pid = self ._setup_flare_logging (flare_log_level_int )
93
77
self ._generate_config_file (pid )
94
78
95
79
def send (self , flare_send_req : FlareSendRequest ):
@@ -98,36 +82,16 @@ def send(self, flare_send_req: FlareSendRequest):
98
82
before sending the flare.
99
83
"""
100
84
self .revert_configs ()
101
- # We only want the flare to be sent once, even if there are
102
- # multiple tracer instances
103
- lock_path = self .flare_dir / TRACER_FLARE_LOCK
104
- if not os .path .exists (lock_path ):
105
- try :
106
- open (lock_path , "w" ).close ()
107
- except Exception as e :
108
- log .error ("Failed to create %s file" , lock_path )
109
- raise e
110
- try :
111
- client = get_connection (self .url , timeout = self .timeout )
112
- headers , body = self ._generate_payload (flare_send_req .__dict__ )
113
- client .request ("POST" , TRACER_FLARE_ENDPOINT , body , headers )
114
- response = client .getresponse ()
115
- if response .status == 200 :
116
- log .info ("Successfully sent the flare to Zendesk ticket %s" , flare_send_req .case_id )
117
- else :
118
- msg = "Tracer flare upload responded with status code %s:(%s) %s" % (
119
- response .status ,
120
- response .reason ,
121
- response .read ().decode (),
122
- )
123
- raise TracerFlareSendError (msg )
124
- except Exception as e :
125
- log .error ("Failed to send tracer flare to Zendesk ticket %s: %s" , flare_send_req .case_id , e )
126
- raise e
127
- finally :
128
- client .close ()
129
- # Clean up files regardless of success/failure
130
- self .clean_up_files ()
85
+
86
+ # Ensure the flare directory exists (it might have been deleted by clean_up_files)
87
+ self .flare_dir .mkdir (exist_ok = True )
88
+
89
+ try :
90
+ if not self ._validate_case_id (flare_send_req .case_id ):
91
+ return
92
+ self ._send_flare_request (flare_send_req )
93
+ finally :
94
+ self .clean_up_files ()
131
95
132
96
def _generate_config_file (self , pid : int ):
133
97
config_file = self .flare_dir / f"tracer_config_{ pid } .json"
@@ -161,41 +125,134 @@ def revert_configs(self):
161
125
log .debug ("Could not find %s to remove" , TRACER_FLARE_FILE_HANDLER_NAME )
162
126
ddlogger .setLevel (self .original_log_level )
163
127
164
- def _generate_payload (self , params : Dict [str , str ]) -> Tuple [dict , bytes ]:
128
+ def _validate_case_id (self , case_id : str ) -> bool :
129
+ """
130
+ Validate case_id (must be a digit and cannot be 0 according to spec).
131
+ Returns True if valid, False otherwise. Cleans up files if invalid.
132
+ """
133
+ if case_id in ("0" , 0 ):
134
+ log .warning ("Case ID cannot be 0, skipping flare send" )
135
+ return False
136
+
137
+ if not case_id .isdigit ():
138
+ log .warning ("Case ID string must contain a digit, skipping flare send" )
139
+ return False
140
+
141
+ return True
142
+
143
+ def _setup_flare_logging (self , flare_log_level_int : int ) -> int :
144
+ """
145
+ Setup flare logging configuration.
146
+ Returns the process ID.
147
+ """
148
+ ddlogger = get_logger ("ddtrace" )
149
+ pid = os .getpid ()
150
+ flare_file_path = self .flare_dir / f"tracer_python_{ pid } .log"
151
+ self .original_log_level = ddlogger .level
152
+
153
+ # Set the logger level to the more verbose between original and flare
154
+ # We do this valid_original_level check because if the log level is NOTSET, the value is 0
155
+ # which is the minimum value. In this case, we just want to use the flare level, but still
156
+ # retain the original state as NOTSET/0
157
+ valid_original_level = (
158
+ logging .CRITICAL if self .original_log_level == logging .NOTSET else self .original_log_level
159
+ )
160
+ logger_level = min (valid_original_level , flare_log_level_int )
161
+ ddlogger .setLevel (logger_level )
162
+ self .file_handler = _add_file_handler (
163
+ ddlogger , flare_file_path .__str__ (), flare_log_level_int , TRACER_FLARE_FILE_HANDLER_NAME
164
+ )
165
+ return pid
166
+
167
+ def _create_zip_content (self ) -> bytes :
168
+ """
169
+ Create ZIP file content containing all flare files.
170
+ Returns the ZIP file content as bytes.
171
+ """
165
172
zip_stream = io .BytesIO ()
166
173
with zipfile .ZipFile (zip_stream , mode = "w" , compression = zipfile .ZIP_DEFLATED ) as zipf :
167
174
for flare_file_name in self .flare_dir .iterdir ():
168
175
zipf .write (flare_file_name , arcname = flare_file_name .name )
169
176
zip_stream .seek (0 )
177
+ return zip_stream .getvalue ()
170
178
171
- newline = b"\r \n "
179
+ def _write_body_field (self , body : io .BytesIO , name : str , value : str ):
180
+ """Write a form field to the multipart body."""
181
+ body .write (f"--{ self ._BOUNDARY } \r \n " .encode ())
182
+ body .write (f'Content-Disposition: form-data; name="{ name } "\r \n \r \n ' .encode ())
183
+ body .write (f"{ value } \r \n " .encode ())
172
184
173
- boundary = binascii .hexlify (os .urandom (16 ))
185
+ def _generate_payload (self , flare_send_req ):
186
+ """
187
+ Generate the multipart form-data payload for the flare request.
188
+ """
189
+
190
+ # Create the multipart form data in the same order:
191
+ # source, case_id, hostname, email, uuid, flare_file
174
192
body = io .BytesIO ()
175
- for key , value in params .items ():
176
- encoded_key = key .encode ()
177
- encoded_value = value .encode ()
178
- body .write (b"--" + boundary + newline )
179
- body .write (b'Content-Disposition: form-data; name="%s"%s%s' % (encoded_key , newline , newline ))
180
- body .write (b"%s%s" % (encoded_value , newline ))
181
-
182
- body .write (b"--" + boundary + newline )
183
- body .write ((b'Content-Disposition: form-data; name="flare_file"; filename="flare.zip"%s' % newline ))
184
- body .write (b"Content-Type: application/octet-stream%s%s" % (newline , newline ))
185
- body .write (zip_stream .getvalue () + newline )
186
- body .write (b"--" + boundary + b"--" )
193
+ self ._write_body_field (body , "source" , "tracer_python" )
194
+ self ._write_body_field (body , "case_id" , flare_send_req .case_id )
195
+ self ._write_body_field (body , "hostname" , flare_send_req .hostname )
196
+ self ._write_body_field (body , "email" , flare_send_req .email )
197
+ self ._write_body_field (body , "uuid" , flare_send_req .uuid )
198
+
199
+ # flare_file field with descriptive filename
200
+ timestamp = int (time .time () * 1000 )
201
+ filename = f"tracer-python-{ flare_send_req .case_id } -{ timestamp } -debug.zip"
202
+ body .write (f"--{ self ._BOUNDARY } \r \n " .encode ())
203
+ body .write (f'Content-Disposition: form-data; name="flare_file"; filename="{ filename } "\r \n ' .encode ())
204
+ body .write (b"Content-Type: application/octet-stream\r \n \r \n " )
205
+
206
+ # Create the zip file content separately
207
+ body .write (self ._create_zip_content () + b"\r \n " )
208
+
209
+ # Ending boundary
210
+ body .write (f"--{ self ._BOUNDARY } --\r \n " .encode ())
211
+
212
+ # Set headers
187
213
headers = {
188
- "Content-Type" : b "multipart/form-data; boundary=%s" % boundary ,
189
- "Content-Length" : body .getbuffer (). nbytes ,
214
+ "Content-Type" : f "multipart/form-data; boundary={ self . _BOUNDARY } " ,
215
+ "Content-Length" : str ( body .tell ()) ,
190
216
}
191
- if self . _api_key :
192
- headers [ " DD-API-KEY" ] = self . _api_key
217
+
218
+ # Note: don't send DD-API-KEY or Host Header - the agent should add it when forwarding to backend
193
219
return headers , body .getvalue ()
194
220
195
221
def _get_valid_logger_level (self , flare_log_level : int ) -> int :
196
222
valid_original_level = 100 if self .original_log_level == 0 else self .original_log_level
197
223
return min (valid_original_level , flare_log_level )
198
224
225
+ def _send_flare_request (self , flare_send_req : FlareSendRequest ):
226
+ """
227
+ Send the flare request to the agent.
228
+ """
229
+ # We only want the flare to be sent once, even if there are
230
+ # multiple tracer instances
231
+ lock_path = self .flare_dir / TRACER_FLARE_LOCK
232
+ if not os .path .exists (lock_path ):
233
+ open (lock_path , "w" ).close ()
234
+ client = None
235
+ try :
236
+ client = get_connection (self .url , timeout = self .timeout )
237
+ headers , body = self ._generate_payload (flare_send_req )
238
+ client .request ("POST" , TRACER_FLARE_ENDPOINT , body , headers )
239
+ response = client .getresponse ()
240
+ if response .status == 200 :
241
+ log .info ("Successfully sent the flare to Zendesk ticket %s" , flare_send_req .case_id )
242
+ else :
243
+ msg = "Tracer flare upload responded with status code %s:(%s) %s" % (
244
+ response .status ,
245
+ response .reason ,
246
+ response .read ().decode (),
247
+ )
248
+ raise TracerFlareSendError (msg )
249
+ except Exception as e :
250
+ log .error ("Failed to send tracer flare to Zendesk ticket %s: %s" , flare_send_req .case_id , e )
251
+ raise e
252
+ finally :
253
+ if client is not None :
254
+ client .close ()
255
+
199
256
def clean_up_files (self ):
200
257
try :
201
258
shutil .rmtree (self .flare_dir )
0 commit comments