diff --git a/src/Attachment/Attachment.php b/src/Attachment/Attachment.php new file mode 100644 index 000000000..9fdeea438 --- /dev/null +++ b/src/Attachment/Attachment.php @@ -0,0 +1,75 @@ +filename = $filename; + $this->contentType = $contentType; + } + + public function getFilename(): string + { + return $this->filename; + } + + public function getContentType(): string + { + return $this->contentType; + } + + /** + * Returns the size in bytes for the attachment. This method should aim to use a low overhead + * way of determining the size because it will be called more than once. + * For example, for file attachments it should read the file size from the filesystem instead of + * reading the file in memory and then calculating the length. + * If no low overhead way exists, then the result should be cached so that calling it multiple times + * does not decrease performance. + * + * @return int the size in bytes or null if the length could not be determined, for example if the file + * does not exist + */ + abstract public function getSize(): ?int; + + /** + * Fetches and returns the data. Calling this can have a non-trivial impact on memory usage, depending + * on the type and size of attachment. + * + * @return string the content as bytes or null if the content could not be retrieved, for example if the file + * does not exist + */ + abstract public function getData(): ?string; + + /** + * Creates a new attachment representing a file referenced by a path. + * The file is not validated and the content is not read when creating the attachment. + */ + public static function fromFile(string $path, string $contentType = self::DEFAULT_CONTENT_TYPE): Attachment + { + return new FileAttachment($path, $contentType); + } + + /** + * Creates a new attachment representing a slice of bytes that lives in memory. + */ + public static function fromBytes(string $filename, string $data, string $contentType = self::DEFAULT_CONTENT_TYPE): Attachment + { + return new ByteAttachment($filename, $contentType, $data); + } +} diff --git a/src/Attachment/ByteAttachment.php b/src/Attachment/ByteAttachment.php new file mode 100644 index 000000000..35f8dc5a4 --- /dev/null +++ b/src/Attachment/ByteAttachment.php @@ -0,0 +1,32 @@ +data = $data; + } + + public function getSize(): ?int + { + return \strlen($this->data); + } + + public function getData(): ?string + { + return $this->data; + } +} diff --git a/src/Attachment/FileAttachment.php b/src/Attachment/FileAttachment.php new file mode 100644 index 000000000..881121ca4 --- /dev/null +++ b/src/Attachment/FileAttachment.php @@ -0,0 +1,32 @@ +path = $path; + } + + public function getSize(): ?int + { + return @filesize($this->path) ?: null; + } + + public function getData(): ?string + { + return @file_get_contents($this->path) ?: null; + } +} diff --git a/src/Event.php b/src/Event.php index 18189eb5e..ff3db9343 100644 --- a/src/Event.php +++ b/src/Event.php @@ -4,6 +4,7 @@ namespace Sentry; +use Sentry\Attachment\Attachment; use Sentry\Context\OsContext; use Sentry\Context\RuntimeContext; use Sentry\Logs\Log; @@ -204,6 +205,11 @@ final class Event */ private $profile; + /** + * @var Attachment[] + */ + private $attachments = []; + private function __construct(?EventId $eventId, EventType $eventType) { $this->id = $eventId ?? EventId::generate(); @@ -933,4 +939,20 @@ public function getTraceId(): ?string return null; } + + /** + * @return Attachment[] + */ + public function getAttachments(): array + { + return $this->attachments; + } + + /** + * @param Attachment[] $attachments + */ + public function setAttachments(array $attachments): void + { + $this->attachments = $attachments; + } } diff --git a/src/Serializer/EnvelopItems/AttachmentItem.php b/src/Serializer/EnvelopItems/AttachmentItem.php new file mode 100644 index 000000000..14ddf5a42 --- /dev/null +++ b/src/Serializer/EnvelopItems/AttachmentItem.php @@ -0,0 +1,40 @@ +getData(); + if ($data === null) { + return null; + } + + $header = [ + 'type' => 'attachment', + 'filename' => $attachment->getFilename(), + 'content_type' => $attachment->getContentType(), + 'attachment_type' => 'event.attachment', + 'length' => $attachment->getSize(), + ]; + + return \sprintf("%s\n%s", JSON::encode($header), $data); + } + + public static function toEnvelopeItem(Event $event): ?string + { + $result = []; + foreach ($event->getAttachments() as $attachment) { + $result[] = self::toAttachmentItem($attachment); + } + + return implode("\n", $result); + } +} diff --git a/src/Serializer/PayloadSerializer.php b/src/Serializer/PayloadSerializer.php index 4878cc767..78b5a335d 100644 --- a/src/Serializer/PayloadSerializer.php +++ b/src/Serializer/PayloadSerializer.php @@ -7,6 +7,7 @@ use Sentry\Event; use Sentry\EventType; use Sentry\Options; +use Sentry\Serializer\EnvelopItems\AttachmentItem; use Sentry\Serializer\EnvelopItems\CheckInItem; use Sentry\Serializer\EnvelopItems\EventItem; use Sentry\Serializer\EnvelopItems\LogsItem; @@ -60,12 +61,14 @@ public function serialize(Event $event): string switch ($event->getType()) { case EventType::event(): $items[] = EventItem::toEnvelopeItem($event); + $items[] = AttachmentItem::toEnvelopeItem($event); break; case EventType::transaction(): $items[] = TransactionItem::toEnvelopeItem($event); if ($event->getSdkMetadata('profile') !== null) { $items[] = ProfileItem::toEnvelopeItem($event); } + $items[] = AttachmentItem::toEnvelopeItem($event); break; case EventType::checkIn(): $items[] = CheckInItem::toEnvelopeItem($event); diff --git a/src/State/Hub.php b/src/State/Hub.php index eeaf7bb28..2ab9930e9 100644 --- a/src/State/Hub.php +++ b/src/State/Hub.php @@ -5,6 +5,7 @@ namespace Sentry\State; use Psr\Log\NullLogger; +use Sentry\Attachment\Attachment; use Sentry\Breadcrumb; use Sentry\CheckIn; use Sentry\CheckInStatus; @@ -231,6 +232,19 @@ public function addBreadcrumb(Breadcrumb $breadcrumb): bool return $breadcrumb !== null; } + public function addAttachment(Attachment $attachment): bool + { + $client = $this->getClient(); + + if ($client === null) { + return false; + } + + $this->getScope()->addAttachment($attachment); + + return true; + } + /** * {@inheritdoc} */ diff --git a/src/State/HubAdapter.php b/src/State/HubAdapter.php index 503153860..1c10b4956 100644 --- a/src/State/HubAdapter.php +++ b/src/State/HubAdapter.php @@ -4,6 +4,7 @@ namespace Sentry\State; +use Sentry\Attachment\Attachment; use Sentry\Breadcrumb; use Sentry\CheckInStatus; use Sentry\ClientInterface; @@ -155,6 +156,14 @@ public function addBreadcrumb(Breadcrumb $breadcrumb): bool return SentrySdk::getCurrentHub()->addBreadcrumb($breadcrumb); } + /** + * {@inheritDoc} + */ + public function addAttachment(Attachment $attachment): bool + { + return SentrySdk::getCurrentHub()->addAttachment($attachment); + } + /** * {@inheritdoc} */ diff --git a/src/State/HubInterface.php b/src/State/HubInterface.php index 227a8451e..27751564f 100644 --- a/src/State/HubInterface.php +++ b/src/State/HubInterface.php @@ -4,6 +4,7 @@ namespace Sentry\State; +use Sentry\Attachment\Attachment; use Sentry\Breadcrumb; use Sentry\CheckInStatus; use Sentry\ClientInterface; @@ -152,4 +153,9 @@ public function getSpan(): ?Span; * Sets the span on the Hub. */ public function setSpan(?Span $span): HubInterface; + + /** + * Records a new attachment that will be attached to error and transaction events. + */ + public function addAttachment(Attachment $attachment): bool; } diff --git a/src/State/Scope.php b/src/State/Scope.php index e4e054c3c..87706f446 100644 --- a/src/State/Scope.php +++ b/src/State/Scope.php @@ -4,9 +4,11 @@ namespace Sentry\State; +use Sentry\Attachment\Attachment; use Sentry\Breadcrumb; use Sentry\Event; use Sentry\EventHint; +use Sentry\EventType; use Sentry\Options; use Sentry\Severity; use Sentry\Tracing\DynamicSamplingContext; @@ -75,6 +77,11 @@ class Scope */ private $span; + /** + * @var Attachment[] + */ + private $attachments = []; + /** * @var callable[] List of event processors * @@ -333,6 +340,7 @@ public function clear(): self $this->tags = []; $this->extra = []; $this->contexts = []; + $this->attachments = []; return $this; } @@ -411,6 +419,12 @@ public function applyToEvent(Event $event, ?EventHint $hint = null, ?Options $op $hint = new EventHint(); } + if ($event->getType() === EventType::event() || $event->getType() === EventType::transaction()) { + if (empty($event->getAttachments())) { + $event->setAttachments($this->attachments); + } + } + foreach (array_merge(self::$globalEventProcessors, $this->eventProcessors) as $processor) { $event = $processor($event, $hint); @@ -481,4 +495,18 @@ public function __clone() $this->propagationContext = clone $this->propagationContext; } } + + public function addAttachment(Attachment $attachment): self + { + $this->attachments[] = $attachment; + + return $this; + } + + public function clearAttachments(): self + { + $this->attachments = []; + + return $this; + } } diff --git a/tests/Attachment/AttachmentTest.php b/tests/Attachment/AttachmentTest.php new file mode 100644 index 000000000..074d96bd3 --- /dev/null +++ b/tests/Attachment/AttachmentTest.php @@ -0,0 +1,54 @@ +assertEquals(25, $attachment->getSize()); + $this->assertEquals('This is a temp attachment', $attachment->getData()); + $this->assertStringStartsWith('att', $attachment->getFilename()); + $this->assertEquals('application/octet-stream', $attachment->getContentType()); + } + + public function testEmptyFile(): void + { + $file = tempnam(sys_get_temp_dir(), 'attachment.txt'); + $attachment = Attachment::fromFile($file); + $this->assertEquals(0, $attachment->getSize()); + $this->assertEquals('', $attachment->getData()); + } + + public function testFileDoesNotExist(): void + { + $attachment = Attachment::fromFile('this/does/not/exist'); + $this->assertNull($attachment->getSize()); + $this->assertNull($attachment->getData()); + } + + public function testByteAttachment(): void + { + $attachment = Attachment::fromBytes('test', 'ExampleDataThatShouldNotBeAFile'); + $this->assertEquals(31, $attachment->getSize()); + $this->assertEquals('ExampleDataThatShouldNotBeAFile', $attachment->getData()); + $this->assertEquals('test', $attachment->getFilename()); + $this->assertEquals('application/octet-stream', $attachment->getContentType()); + } + + public function testEmptyBytes(): void + { + $attachment = Attachment::fromBytes('test', ''); + $this->assertEquals(0, $attachment->getSize()); + $this->assertEquals('', $attachment->getData()); + } +} diff --git a/tests/Serializer/PayloadSerializerTest.php b/tests/Serializer/PayloadSerializerTest.php index 3ae6a5777..963a46ce8 100644 --- a/tests/Serializer/PayloadSerializerTest.php +++ b/tests/Serializer/PayloadSerializerTest.php @@ -5,6 +5,7 @@ namespace Sentry\Tests\Serializer; use PHPUnit\Framework\TestCase; +use Sentry\Attachment\Attachment; use Sentry\Breadcrumb; use Sentry\CheckIn; use Sentry\CheckInStatus; @@ -422,6 +423,59 @@ public static function serializeAsEnvelopeDataProvider(): iterable {"event_id":"fc9442f5aef34234bb22b9a615e30ccd","sent_at":"2020-08-18T22:47:15Z","dsn":"http:\/\/public@example.com\/sentry\/1","sdk":{"name":"sentry.php","version":"$sdkVersion","packages":[{"name":"composer:sentry\/sentry","version":"$sdkVersion"}]}} {"type":"log","item_count":1,"content_type":"application\/vnd.sentry.items.log+json"} {"items":[{"timestamp":1597790835,"trace_id":"21160e9b836d479f81611368b2aa3d2c","level":"info","body":"A log message","attributes":{"foo":{"type":"string","value":"bar"}}}]} +TEXT + , + ]; + + // Test in memory attachment + $event = Event::createEvent(new EventId('fc9442f5aef34234bb22b9a615e30ccd')); + $event->setAttachments([ + Attachment::fromBytes('test.attachment', 'This is a test attachment stored in memory'), + ]); + + yield [ + $event, + <<setAttachments([ + Attachment::fromFile(realpath(__DIR__ . '/../data/attachment.txt')), + ]); + + yield [ + $event, + <<setAttachments([ + Attachment::fromFile('does not exist'), + ]); + + yield [ + $event, + <<assertSame('foo', $dynamicSamplingContext->get('transaction')); $this->assertSame('566e3688a61d4bc888951642d6f14a19', $dynamicSamplingContext->get('trace_id')); } + + /** + * @dataProvider eventWithLogCountProvider + */ + public function testAttachmentsAppliedForType(Event $event, int $attachmentCount): void + { + $scope = new Scope(); + $scope->addAttachment(Attachment::fromBytes('test', 'abcde')); + $scope->applyToEvent($event); + $this->assertCount($attachmentCount, $event->getAttachments()); + } + + public function eventWithLogCountProvider(): \Generator + { + yield 'event' => [Event::createEvent(), 1]; + yield 'transaction' => [Event::createTransaction(), 1]; + yield 'check-in' => [Event::createCheckIn(), 0]; + yield 'logs' => [Event::createLogs(), 0]; + } } diff --git a/tests/data/attachment.txt b/tests/data/attachment.txt new file mode 100644 index 000000000..fd2a063ce --- /dev/null +++ b/tests/data/attachment.txt @@ -0,0 +1 @@ +This is an attachment that is stored on the disk!