Repository: getsentry/sentry-php (php)

Issue:
Allow to create & send Envelopes
### Problem Statement

The majority of event ingestion these days is done via sending [envelopes](https://develop.sentry.dev/sdk/envelopes/) to any Relay.
The SDK currently lacks a proper abstraction for this, making it hard to implement upcoming features like Cron Monitoring.

### Solution Brainstorm

Add a new abstraction around envelopes. The goal should be an API to create a valid envelope, including headers, items, and the payload.
It might be tricky to properly expose the `Transport::send` method, as it is currently part of the `TransportInterface` and expects an `Event` instead of an `Envelope` instance being passed.

However, this should be implemented in a none BC breaking way.

Pull request description:
feat: Add support for Cron Monitoring
This adds support to send [Cron Monitor](https://docs.sentry.io/product/crons/) check-ins as envelopes to Relay.

```php
<?php
$checkIn = new CheckIn(
    monitorSlug: getenv('MONITOR_SLUG'),
    status: CheckInStatus::inProgress(),
);

$event = Event::createCheckIn();
$event->setCheckIn($checkIn);

$this->hub->captureEvent($event);

// do stuff

$event = Event::createCheckIn();
$event->setCheckIn(new CheckIn(
    id: $checkIn->getId(),
    monitorSlug: getenv('MONITOR_SLUG'),
    status: CheckInStatus::ok(),
));

$this->hub->captureEvent($event);
```

Patch:
diff --git a/src/CheckIn.php b/src/CheckIn.php
new file mode 100644
index 0000000..4257b5a
--- /dev/null
+++ b/src/CheckIn.php
@@ -0,0 +1,117 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Sentry;
+
+use Sentry\Util\SentryUid;
+
+final class CheckIn
+{
+    /**
+     * @var string The check-in ID
+     */
+    private $id;
+
+    /**
+     * @var string The monitor slug
+     */
+    private $monitorSlug;
+
+    /**
+     * @var \Sentry\CheckInStatus The status of the check-in
+     */
+    private $status;
+
+    /**
+     * @var string|null The release
+     */
+    private $release;
+
+    /**
+     * @var string|null The environment
+     */
+    private $environment;
+
+    /**
+     * @var int|null The duration of the check in seconds
+     */
+    private $duration;
+
+    public function __construct(
+        string $monitorSlug,
+        CheckInStatus $status,
+        string $id = null,
+        ?string $release = null,
+        ?string $environment = null,
+        ?int $duration = null
+    ) {
+        $this->setMonitorSlug($monitorSlug);
+        $this->setStatus($status);
+
+        $this->setId($id ?? SentryUid::generate());
+        $this->setRelease($release ?? '');
+        $this->setEnvironment($environment ?? Event::DEFAULT_ENVIRONMENT);
+        $this->setDuration($duration);
+    }
+
+    public function getId(): string
+    {
+        return $this->id;
+    }
+
+    public function setId(string $id): void
+    {
+        $this->id = $id;
+    }
+
+    public function getMonitorSlug(): string
+    {
+        return $this->monitorSlug;
+    }
+
+    public function setMonitorSlug(string $monitorSlug): void
+    {
+        $this->monitorSlug = $monitorSlug;
+    }
+
+    public function getStatus(): CheckInStatus
+    {
+        return $this->status;
+    }
+
+    public function setStatus(CheckInStatus $status): void
+    {
+        $this->status = $status;
+    }
+
+    public function getRelease(): ?string
+    {
+        return $this->release;
+    }
+
+    public function setRelease(string $release): void
+    {
+        $this->release = $release;
+    }
+
+    public function getEnvironment(): ?string
+    {
+        return $this->environment;
+    }
+
+    public function setEnvironment(string $environment): void
+    {
+        $this->environment = $environment;
+    }
+
+    public function getDuration(): ?int
+    {
+        return $this->duration;
+    }
+
+    public function setDuration(?int $duration): void
+    {
+        $this->duration = $duration;
+    }
+}
diff --git a/src/CheckInStatus.php b/src/CheckInStatus.php
new file mode 100644
index 0000000..c295471
--- /dev/null
+++ b/src/CheckInStatus.php
@@ -0,0 +1,55 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Sentry;
+
+/**
+ * This enum represents all the possible status of a check in.
+ */
+final class CheckInStatus implements \Stringable
+{
+    /**
+     * @var string The value of the enum instance
+     */
+    private $value;
+
+    /**
+     * @var array<string, self> A list of cached enum instances
+     */
+    private static $instances = [];
+
+    private function __construct(string $value)
+    {
+        $this->value = $value;
+    }
+
+    public static function ok(): self
+    {
+        return self::getInstance('ok');
+    }
+
+    public static function error(): self
+    {
+        return self::getInstance('error');
+    }
+
+    public static function inProgress(): self
+    {
+        return self::getInstance('in_progress');
+    }
+
+    public function __toString(): string
+    {
+        return $this->value;
+    }
+
+    private static function getInstance(string $value): self
+    {
+        if (!isset(self::$instances[$value])) {
+            self::$instances[$value] = new self($value);
+        }
+
+        return self::$instances[$value];
+    }
+}
diff --git a/src/Event.php b/src/Event.php
index 8f38d7a..41d93ef 100644
--- a/src/Event.php
+++ b/src/Event.php
@@ -50,6 +50,11 @@ final class Event
      */
     private $transaction;
 
+    /**
+     * @var CheckIn|null The check in data
+     */
+    private $checkIn;
+
     /**
      * @var string|null The name of the server (e.g. the host name)
      */
@@ -200,6 +205,11 @@ public static function createTransaction(EventId $eventId = null): self
         return new self($eventId, EventType::transaction());
     }
 
+    public static function createCheckIn(?EventId $eventId = null): self
+    {
+        return new self($eventId, EventType::checkIn());
+    }
+
     /**
      * Gets the ID of this event.
      */
@@ -322,6 +332,16 @@ public function setTransaction(?string $transaction): void
         $this->transaction = $transaction;
     }
 
+    public function setCheckIn(?CheckIn $checkIn): void
+    {
+        $this->checkIn = $checkIn;
+    }
+
+    public function getCheckIn(): ?CheckIn
+    {
+        return $this->checkIn;
+    }
+
     /**
      * Gets the name of the server.
      */
diff --git a/src/EventType.php b/src/EventType.php
index 0dc9e53..c4e79c4 100644
--- a/src/EventType.php
+++ b/src/EventType.php
@@ -48,6 +48,11 @@ public static function transaction(): self
         return self::getInstance('transaction');
     }
 
+    public static function checkIn(): self
+    {
+        return self::getInstance('check_in');
+    }
+
     public function __toString(): string
     {
         return $this->value;
diff --git a/src/Serializer/PayloadSerializer.php b/src/Serializer/PayloadSerializer.php
index 9eeaff0..09155fc 100644
--- a/src/Serializer/PayloadSerializer.php
+++ b/src/Serializer/PayloadSerializer.php
@@ -53,6 +53,10 @@ public function serialize(Event $event): string
             return $transactionEnvelope;
         }
 
+        if (EventType::checkIn() === $event->getType()) {
+            return $this->serializeAsEnvelope($event);
+        }
+
         return $this->serializeAsEvent($event);
     }
 
@@ -63,6 +67,25 @@ private function serializeAsEvent(Event $event): string
         return JSON::encode($result);
     }
 
+    private function serializeAsCheckInEvent(Event $event): string
+    {
+        $result = [];
+
+        $checkIn = $event->getCheckIn();
+        if (null !== $checkIn) {
+            $result = [
+                'check_in_id' => $checkIn->getId(),
+                'monitor_slug' => $checkIn->getMonitorSlug(),
+                'status' => (string) $checkIn->getStatus(),
+                'duration' => $checkIn->getDuration(),
+                'release' => $checkIn->getRelease(),
+                'environment' => $checkIn->getEnvironment(),
+            ];
+        }
+
+        return JSON::encode($result);
+    }
+
     /**
      * @return array<string, mixed>
      */
@@ -231,7 +254,15 @@ private function serializeAsEnvelope(Event $event): string
             'content_type' => 'application/json',
         ];
 
-        return sprintf("%s\n%s\n%s", JSON::encode($envelopeHeader), JSON::encode($itemHeader), $this->serializeAsEvent($event));
+        $seralizedEvent = '';
+        if (EventType::transaction() === $event->getType()) {
+            $seralizedEvent = $this->serializeAsEvent($event);
+        }
+        if (EventType::checkIn() === $event->getType()) {
+            $seralizedEvent = $this->serializeAsCheckInEvent($event);
+        }
+
+        return sprintf("%s\n%s\n%s", JSON::encode($envelopeHeader), JSON::encode($itemHeader), $seralizedEvent);
     }
 
     private function seralizeProfileAsEnvelope(Event $event): ?string
diff --git a/src/Transport/HttpTransport.php b/src/Transport/HttpTransport.php
index c3ca0d0..74186be 100644
--- a/src/Transport/HttpTransport.php
+++ b/src/Transport/HttpTransport.php
@@ -112,7 +112,10 @@ public function send(Event $event): PromiseInterface
             return new RejectedPromise(new Response(ResponseStatus::rateLimit(), $event));
         }
 
-        if (EventType::transaction() === $eventType) {
+        if (
+            EventType::transaction() === $eventType ||
+            EventType::checkIn() === $eventType
+        ) {
             $request = $this->requestFactory->createRequest('POST', $dsn->getEnvelopeApiEndpointUrl())
                 ->withHeader('Content-Type', 'application/x-sentry-envelope')
                 ->withBody($this->streamFactory->createStream($this->payloadSerializer->serialize($event)));


Test patch:
diff --git a/tests/CheckInTest.php b/tests/CheckInTest.php
new file mode 100644
index 0000000..4380ebd
--- /dev/null
+++ b/tests/CheckInTest.php
@@ -0,0 +1,59 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Sentry\Tests;
+
+use PHPUnit\Framework\TestCase;
+use Sentry\CheckIn;
+use Sentry\CheckInStatus;
+use Sentry\Util\SentryUid;
+
+final class CheckInTest extends TestCase
+{
+    public function testConstructor(): void
+    {
+        $checkInId = SentryUid::generate();
+        $checkIn = new CheckIn(
+            'my-monitor',
+            CheckInStatus::ok(),
+            $checkInId,
+            '1.0.0',
+            'dev',
+            10
+        );
+
+        $this->assertEquals($checkInId, $checkIn->getId());
+        $this->assertEquals('my-monitor', $checkIn->getMonitorSlug());
+        $this->assertEquals('ok', $checkIn->getStatus());
+        $this->assertEquals('1.0.0', $checkIn->getRelease());
+        $this->assertEquals('dev', $checkIn->getEnvironment());
+        $this->assertEquals(10, $checkIn->getDuration());
+    }
+
+    /**
+     * @dataProvider gettersAndSettersDataProvider
+     */
+    public function testGettersAndSetters(string $getterMethod, string $setterMethod, $expectedData): void
+    {
+        $checkIn = new CheckIn(
+            'my-monitor',
+            CheckInStatus::ok()
+        );
+        $checkIn->$setterMethod($expectedData);
+
+        $this->assertEquals($expectedData, $checkIn->$getterMethod());
+    }
+
+    public function gettersAndSettersDataProvider(): array
+    {
+        return [
+            ['getId', 'setId', SentryUid::generate()],
+            ['getMonitorSlug', 'setMonitorSlug', 'my-monitor'],
+            ['getStatus', 'setStatus', CheckInStatus::ok()],
+            ['getRelease', 'setRelease', '1.0.0'],
+            ['getEnvironment', 'setEnvironment', 'dev'],
+            ['getDuration', 'setDuration', 10],
+        ];
+    }
+}
diff --git a/tests/Serializer/PayloadSerializerTest.php b/tests/Serializer/PayloadSerializerTest.php
index ea86631..afafc33 100644
--- a/tests/Serializer/PayloadSerializerTest.php
+++ b/tests/Serializer/PayloadSerializerTest.php
@@ -6,6 +6,8 @@
 
 use PHPUnit\Framework\TestCase;
 use Sentry\Breadcrumb;
+use Sentry\CheckIn;
+use Sentry\CheckInStatus;
 use Sentry\Client;
 use Sentry\Context\OsContext;
 use Sentry\Context\RuntimeContext;
@@ -27,6 +29,7 @@
 use Sentry\Tracing\TraceId;
 use Sentry\Tracing\TransactionMetadata;
 use Sentry\UserDataBag;
+use Sentry\Util\SentryUid;
 use Symfony\Bridge\PhpUnit\ClockMock;
 
 /**
@@ -55,7 +58,10 @@ public function testSerialize(Event $event, string $expectedResult, bool $isOutp
 
         $result = $this->serializer->serialize($event);
 
-        if (EventType::transaction() !== $event->getType()) {
+        if (
+            EventType::transaction() !== $event->getType() &&
+            EventType::checkIn() !== $event->getType()
+        ) {
             $resultArray = $this->serializer->toArray($event);
             $this->assertJsonStringEqualsJsonString($result, json_encode($resultArray));
         }
@@ -538,5 +544,50 @@ public function serializeDataProvider(): iterable
             ,
             true,
         ];
+
+        $checkinId = SentryUid::generate();
+        $checkIn = new CheckIn(
+            'my-monitor',
+            CheckInStatus::ok(),
+            $checkinId,
+            '1.0.0',
+            'dev',
+            10
+        );
+
+        $event = Event::createCheckIn(new EventId('fc9442f5aef34234bb22b9a615e30ccd'));
+        $event->setCheckIn($checkIn);
+
+        yield [
+            $event,
+            <<<TEXT
+{"event_id":"fc9442f5aef34234bb22b9a615e30ccd","sent_at":"2020-08-18T22:47:15Z","dsn":"http:\/\/public@example.com\/sentry\/1","sdk":{"name":"sentry.php","version":"$sdkVersion"}}
+{"type":"check_in","content_type":"application\/json"}
+{"check_in_id":"$checkinId","monitor_slug":"my-monitor","status":"ok","duration":10,"release":"1.0.0","environment":"dev"}
+TEXT
+            ,
+            false,
+        ];
+
+        $checkinId = SentryUid::generate();
+        $checkIn = new CheckIn(
+            'my-monitor',
+            CheckInStatus::inProgress(),
+            $checkinId
+        );
+
+        $event = Event::createCheckIn(new EventId('fc9442f5aef34234bb22b9a615e30ccd'));
+        $event->setCheckIn($checkIn);
+
+        yield [
+            $event,
+            <<<TEXT
+{"event_id":"fc9442f5aef34234bb22b9a615e30ccd","sent_at":"2020-08-18T22:47:15Z","dsn":"http:\/\/public@example.com\/sentry\/1","sdk":{"name":"sentry.php","version":"$sdkVersion"}}
+{"type":"check_in","content_type":"application\/json"}
+{"check_in_id":"$checkinId","monitor_slug":"my-monitor","status":"in_progress","duration":null,"release":"","environment":"production"}
+TEXT
+            ,
+            false,
+        ];
     }
 }