Skip to content

Commit 34d9d73

Browse files
authored
feat(attachments): Add basic support for attachments (#856)
1 parent 44fbdce commit 34d9d73

File tree

6 files changed

+166
-51
lines changed

6 files changed

+166
-51
lines changed

sentry_sdk/attachments.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import os
2+
import mimetypes
3+
4+
from sentry_sdk._types import MYPY
5+
from sentry_sdk.envelope import Item, PayloadRef
6+
7+
if MYPY:
8+
from typing import Optional, Union, Callable
9+
10+
11+
class Attachment(object):
12+
def __init__(
13+
self,
14+
bytes=None, # type: Union[None, bytes, Callable[[], bytes]]
15+
filename=None, # type: Optional[str]
16+
path=None, # type: Optional[str]
17+
content_type=None, # type: Optional[str]
18+
add_to_transactions=False, # type: bool
19+
):
20+
# type: (...) -> None
21+
if bytes is None and path is None:
22+
raise TypeError("path or raw bytes required for attachment")
23+
if filename is None and path is not None:
24+
filename = os.path.basename(path)
25+
if filename is None:
26+
raise TypeError("filename is required for attachment")
27+
if content_type is None:
28+
content_type = mimetypes.guess_type(filename)[0]
29+
self.bytes = bytes
30+
self.filename = filename
31+
self.path = path
32+
self.content_type = content_type
33+
self.add_to_transactions = add_to_transactions
34+
35+
def to_envelope_item(self):
36+
# type: () -> Item
37+
"""Returns an envelope item for this attachment."""
38+
payload = None # type: Union[None, PayloadRef, bytes]
39+
if self.bytes is not None:
40+
if callable(self.bytes):
41+
payload = self.bytes()
42+
else:
43+
payload = self.bytes
44+
else:
45+
payload = PayloadRef(path=self.path)
46+
return Item(
47+
payload=payload,
48+
type="attachment",
49+
content_type=self.content_type,
50+
filename=self.filename,
51+
)
52+
53+
def __repr__(self):
54+
# type: () -> str
55+
return "<Attachment %r>" % (self.filename,)

sentry_sdk/client.py

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
from sentry_sdk.integrations import setup_integrations
2424
from sentry_sdk.utils import ContextVar
2525
from sentry_sdk.sessions import SessionFlusher
26-
from sentry_sdk.envelope import Envelope, Item, PayloadRef
26+
from sentry_sdk.envelope import Envelope
2727

2828
from sentry_sdk._types import MYPY
2929

@@ -146,16 +146,14 @@ def dsn(self):
146146
def _prepare_event(
147147
self,
148148
event, # type: Event
149-
hint, # type: Optional[Hint]
149+
hint, # type: Hint
150150
scope, # type: Optional[Scope]
151151
):
152152
# type: (...) -> Optional[Event]
153153

154154
if event.get("timestamp") is None:
155155
event["timestamp"] = datetime.utcnow()
156156

157-
hint = dict(hint or ()) # type: Hint
158-
159157
if scope is not None:
160158
event_ = scope.apply_to_event(event, hint)
161159
if event_ is None:
@@ -322,10 +320,13 @@ def capture_event(
322320
if hint is None:
323321
hint = {}
324322
event_id = event.get("event_id")
323+
hint = dict(hint or ()) # type: Hint
324+
325325
if event_id is None:
326326
event["event_id"] = event_id = uuid.uuid4().hex
327327
if not self._should_capture(event, hint, scope):
328328
return None
329+
329330
event_opt = self._prepare_event(event, hint, scope)
330331
if event_opt is None:
331332
return None
@@ -336,19 +337,27 @@ def capture_event(
336337
if session:
337338
self._update_session_from_event(session, event)
338339

339-
if event_opt.get("type") == "transaction":
340-
# Transactions should go to the /envelope/ endpoint.
341-
self.transport.capture_envelope(
342-
Envelope(
343-
headers={
344-
"event_id": event_opt["event_id"],
345-
"sent_at": format_timestamp(datetime.utcnow()),
346-
},
347-
items=[
348-
Item(payload=PayloadRef(json=event_opt), type="transaction"),
349-
],
350-
)
340+
attachments = hint.get("attachments")
341+
is_transaction = event_opt.get("type") == "transaction"
342+
343+
if is_transaction or attachments:
344+
# Transactions or events with attachments should go to the
345+
# /envelope/ endpoint.
346+
envelope = Envelope(
347+
headers={
348+
"event_id": event_opt["event_id"],
349+
"sent_at": format_timestamp(datetime.utcnow()),
350+
}
351351
)
352+
353+
if is_transaction:
354+
envelope.add_transaction(event_opt)
355+
else:
356+
envelope.add_event(event_opt)
357+
358+
for attachment in attachments or ():
359+
envelope.add_item(attachment.to_envelope_item())
360+
self.transport.capture_envelope(envelope)
352361
else:
353362
# All other events go to the /store/ endpoint.
354363
self.transport.capture_event(event_opt)

sentry_sdk/envelope.py

Lines changed: 10 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,14 @@
11
import io
22
import json
3-
import shutil
43
import mimetypes
54

65
from sentry_sdk._compat import text_type
76
from sentry_sdk._types import MYPY
87
from sentry_sdk.sessions import Session
9-
from sentry_sdk.tracing import Transaction
10-
from sentry_sdk.utils import json_dumps
8+
from sentry_sdk.utils import json_dumps, capture_internal_exceptions
119

1210
if MYPY:
1311
from typing import Any
14-
from typing import Tuple
1512
from typing import Optional
1613
from typing import Union
1714
from typing import Dict
@@ -24,7 +21,7 @@
2421
class Envelope(object):
2522
def __init__(
2623
self,
27-
headers=None, # type: Optional[Dict[str, str]]
24+
headers=None, # type: Optional[Dict[str, Any]]
2825
items=None, # type: Optional[List[Item]]
2926
):
3027
# type: (...) -> None
@@ -52,7 +49,7 @@ def add_event(
5249
self.add_item(Item(payload=PayloadRef(json=event), type="event"))
5350

5451
def add_transaction(
55-
self, transaction # type: Transaction
52+
self, transaction # type: Event
5653
):
5754
# type: (...) -> None
5855
self.add_item(Item(payload=PayloadRef(json=transaction), type="transaction"))
@@ -148,34 +145,15 @@ def get_bytes(self):
148145
# type: (...) -> bytes
149146
if self.bytes is None:
150147
if self.path is not None:
151-
with open(self.path, "rb") as f:
152-
self.bytes = f.read()
148+
with capture_internal_exceptions():
149+
with open(self.path, "rb") as f:
150+
self.bytes = f.read()
153151
elif self.json is not None:
154152
self.bytes = json_dumps(self.json)
155153
else:
156154
self.bytes = b""
157155
return self.bytes
158156

159-
def _prepare_serialize(self):
160-
# type: (...) -> Tuple[Any, Any]
161-
if self.path is not None and self.bytes is None:
162-
f = open(self.path, "rb")
163-
f.seek(0, 2)
164-
length = f.tell()
165-
f.seek(0, 0)
166-
167-
def writer(out):
168-
# type: (Any) -> None
169-
try:
170-
shutil.copyfileobj(f, out)
171-
finally:
172-
f.close()
173-
174-
return length, writer
175-
176-
bytes = self.get_bytes()
177-
return len(bytes), lambda f: f.write(bytes)
178-
179157
@property
180158
def inferred_content_type(self):
181159
# type: (...) -> str
@@ -199,7 +177,7 @@ class Item(object):
199177
def __init__(
200178
self,
201179
payload, # type: Union[bytes, text_type, PayloadRef]
202-
headers=None, # type: Optional[Dict[str, str]]
180+
headers=None, # type: Optional[Dict[str, Any]]
203181
type=None, # type: Optional[str]
204182
content_type=None, # type: Optional[str]
205183
filename=None, # type: Optional[str]
@@ -279,11 +257,11 @@ def serialize_into(
279257
):
280258
# type: (...) -> None
281259
headers = dict(self.headers)
282-
length, writer = self.payload._prepare_serialize()
283-
headers["length"] = length
260+
bytes = self.get_bytes()
261+
headers["length"] = len(bytes)
284262
f.write(json_dumps(headers))
285263
f.write(b"\n")
286-
writer(f)
264+
f.write(bytes)
287265
f.write(b"\n")
288266

289267
def serialize(self):

sentry_sdk/scope.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from sentry_sdk._types import MYPY
77
from sentry_sdk.utils import logger, capture_internal_exceptions
88
from sentry_sdk.tracing import Transaction
9+
from sentry_sdk.attachments import Attachment
910

1011
if MYPY:
1112
from typing import Any
@@ -90,6 +91,7 @@ class Scope(object):
9091
"_should_capture",
9192
"_span",
9293
"_session",
94+
"_attachments",
9395
"_force_auto_session_tracking",
9496
)
9597

@@ -112,6 +114,7 @@ def clear(self):
112114
self._tags = {} # type: Dict[str, Any]
113115
self._contexts = {} # type: Dict[str, Dict[str, Any]]
114116
self._extras = {} # type: Dict[str, Any]
117+
self._attachments = [] # type: List[Attachment]
115118

116119
self.clear_breadcrumbs()
117120
self._should_capture = True
@@ -251,6 +254,26 @@ def clear_breadcrumbs(self):
251254
"""Clears breadcrumb buffer."""
252255
self._breadcrumbs = deque() # type: Deque[Breadcrumb]
253256

257+
def add_attachment(
258+
self,
259+
bytes=None, # type: Optional[bytes]
260+
filename=None, # type: Optional[str]
261+
path=None, # type: Optional[str]
262+
content_type=None, # type: Optional[str]
263+
add_to_transactions=False, # type: bool
264+
):
265+
# type: (...) -> None
266+
"""Adds an attachment to future events sent."""
267+
self._attachments.append(
268+
Attachment(
269+
bytes=bytes,
270+
path=path,
271+
filename=filename,
272+
content_type=content_type,
273+
add_to_transactions=add_to_transactions,
274+
)
275+
)
276+
254277
def add_event_processor(
255278
self, func # type: EventProcessor
256279
):
@@ -310,10 +333,21 @@ def _drop(event, cause, ty):
310333
logger.info("%s (%s) dropped event (%s)", ty, cause, event)
311334
return None
312335

336+
is_transaction = event.get("type") == "transaction"
337+
338+
# put all attachments into the hint. This lets callbacks play around
339+
# with attachments. We also later pull this out of the hint when we
340+
# create the envelope.
341+
attachments_to_send = hint.get("attachments") or []
342+
for attachment in self._attachments:
343+
if not is_transaction or attachment.add_to_transactions:
344+
attachments_to_send.append(attachment)
345+
hint["attachments"] = attachments_to_send
346+
313347
if self._level is not None:
314348
event["level"] = self._level
315349

316-
if event.get("type") != "transaction":
350+
if not is_transaction:
317351
event.setdefault("breadcrumbs", {}).setdefault("values", []).extend(
318352
self._breadcrumbs
319353
)
@@ -379,6 +413,8 @@ def update_from_scope(self, scope):
379413
self._breadcrumbs.extend(scope._breadcrumbs)
380414
if scope._span:
381415
self._span = scope._span
416+
if scope._attachments:
417+
self._attachments.extend(scope._attachments)
382418

383419
def update_from_kwargs(
384420
self,
@@ -425,6 +461,7 @@ def __copy__(self):
425461
rv._span = self._span
426462
rv._session = self._session
427463
rv._force_auto_session_tracking = self._force_auto_session_tracking
464+
rv._attachments = list(self._attachments)
428465

429466
return rv
430467

tests/conftest.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,8 +143,10 @@ def check_string_keys(map):
143143
def check_envelope(envelope):
144144
with capture_internal_exceptions():
145145
# Assert error events are sent without envelope to server, for compat.
146-
assert not any(item.data_category == "error" for item in envelope.items)
147-
assert not any(item.get_event() is not None for item in envelope.items)
146+
# This does not apply if any item in the envelope is an attachment.
147+
if not any(x.type == "attachment" for x in envelope.items):
148+
assert not any(item.data_category == "error" for item in envelope.items)
149+
assert not any(item.get_event() is not None for item in envelope.items)
148150

149151
def inner(client):
150152
monkeypatch.setattr(

tests/test_basics.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import os
12
import logging
23

34
import pytest
@@ -222,6 +223,39 @@ def test_breadcrumbs(sentry_init, capture_events):
222223
assert len(event["breadcrumbs"]["values"]) == 0
223224

224225

226+
def test_attachments(sentry_init, capture_envelopes):
227+
sentry_init()
228+
envelopes = capture_envelopes()
229+
230+
this_file = os.path.abspath(__file__.rstrip("c"))
231+
232+
with configure_scope() as scope:
233+
scope.add_attachment(bytes=b"Hello World!", filename="message.txt")
234+
scope.add_attachment(path=this_file)
235+
236+
capture_exception(ValueError())
237+
238+
(envelope,) = envelopes
239+
240+
assert len(envelope.items) == 3
241+
assert envelope.get_event()["exception"] is not None
242+
243+
attachments = [x for x in envelope.items if x.type == "attachment"]
244+
(message, pyfile) = attachments
245+
246+
assert message.headers["filename"] == "message.txt"
247+
assert message.headers["type"] == "attachment"
248+
assert message.headers["content_type"] == "text/plain"
249+
assert message.payload.bytes == message.payload.get_bytes() == b"Hello World!"
250+
251+
assert pyfile.headers["filename"] == os.path.basename(this_file)
252+
assert pyfile.headers["type"] == "attachment"
253+
assert pyfile.headers["content_type"].startswith("text/")
254+
assert pyfile.payload.bytes is None
255+
with open(this_file, "rb") as f:
256+
assert pyfile.payload.get_bytes() == f.read()
257+
258+
225259
def test_integration_scoping(sentry_init, capture_events):
226260
logger = logging.getLogger("test_basics")
227261

0 commit comments

Comments
 (0)