Skip to content

Commit

Permalink
Uploader: initial support of storing errors in S3-like storage
Browse files Browse the repository at this point in the history
  • Loading branch information
f3l1x committed Nov 25, 2022
1 parent 92a34d3 commit 037f888
Show file tree
Hide file tree
Showing 7 changed files with 298 additions and 1 deletion.
23 changes: 23 additions & 0 deletions .docs/README.md
Expand Up @@ -200,6 +200,29 @@ sentry:

Be careful with `"` and `'`. It does matter.

**S3UploadIntegration**

Upload **ladenka** (error file) to S3. URL is stored as tag in Sentry.

```neon
sentry:
client:
integrations:
- Contributte\Sentry\Integration\S3UploadIntegration()
services:
- Contributte\Sentry\Upload\S3Uploader(
Contributte\Sentry\Upload\S3Signer(
accessKeyId: secret
secretKey: secret
url: myorg.r2.cloudflarestorage.com / s3.eu-central-1.amazonaws.com
bucket: mybucket
region: auto
prefix: null
)
)
```

## Usage

Sentry is successfully integrated to your [Nette](https://nette.org) application and listen for any errors using [Tracy](https://tracy.nette.org).
Expand Down
1 change: 0 additions & 1 deletion README.md
Expand Up @@ -37,7 +37,6 @@ For details on how to use this package, check out our [documentation](.docs).
| dev | `^0.2` | `master` | 2.4+ | `>=7.2` |
| stable | `^0.1` | `master` | 2.4+ | `>=7.2` |


## Development

See [how to contribute](https://contributte.org) to this package. This package is currently maintained by these authors.
Expand Down
3 changes: 3 additions & 0 deletions composer.json
Expand Up @@ -38,6 +38,9 @@
"phpstan/phpstan-strict-rules": "^1.1.0",
"tracy/tracy": "^2.6.0"
},
"suggest": {
"ext-curl": "S3 upload"
},
"conflict": {
"nette/utils": "<2.5.7",
"php-http/discovery": "<1.14.0"
Expand Down
10 changes: 10 additions & 0 deletions src/Exception/Runtime/UploadException.php
@@ -0,0 +1,10 @@
<?php declare(strict_types = 1);

namespace Contributte\Sentry\Exception\Runtime;

use Contributte\Sentry\Exception\RuntimeException;

class UploadException extends RuntimeException
{

}
63 changes: 63 additions & 0 deletions src/Integration/S3UploadIntegration.php
@@ -0,0 +1,63 @@
<?php declare(strict_types = 1);

namespace Contributte\Sentry\Integration;

use Contributte\Sentry\Exception\Runtime\UploadException;
use Contributte\Sentry\Upload\S3Uploader;
use Nette\DI\Container;
use Sentry\Event;
use Sentry\EventHint;
use Sentry\State\HubInterface;
use Tracy\Debugger;
use Tracy\Logger;

class S3UploadIntegration extends BaseIntegration
{

/** @var Container */
protected $context;

public function __construct(Container $context)
{
$this->context = $context;
}

public function setup(HubInterface $hub, Event $event, EventHint $hint): ?Event
{
/** @var S3Uploader|null $uploader */
$uploader = $this->context->getByType(S3Uploader::class, false);

// Required services are missing
if ($uploader === null) {
return $event;
}

$exception = $hint->exception;

// No exception
if ($exception === null) {
return $event;
}

// Use logger from Tracy to calculate filename
$logger = new Logger(Debugger::$logDirectory, Debugger::$email, Debugger::getBlueScreen());
$file = $logger->getExceptionFile($exception);

// Render bluescreen to file
$bs = Debugger::getBlueScreen();
$bs->renderToFile($exception, $file);

// Upload file
try {
$uploaded = $uploader->upload($file);
$event->setTags([
'tracy_file' => $uploaded['url'],
]);
} catch (UploadException $e) {
// Do nothing
}

return $event;
}

}
134 changes: 134 additions & 0 deletions src/Upload/S3Signer.php
@@ -0,0 +1,134 @@
<?php declare(strict_types = 1);

namespace Contributte\Sentry\Upload;

use DateTime;

class S3Signer
{

/** @var DateTime */
private $date;

/** @var string */
private $accessKey;

/** @var string */
private $secretKey;

/** @var string */
private $url;

/** @var string */
private $bucket;

/** @var string */
private $region;

/** @var string|null */
private $prefix;

public function __construct(
string $accessKeyId,
string $secretKey,
string $url,
string $bucket,
string $region = 'auto',
?string $prefix = null
)
{
$this->accessKey = $accessKeyId;
$this->secretKey = $secretKey;
$this->url = $url;
$this->bucket = $bucket;
$this->region = $region;
$this->prefix = $prefix;

$this->date = new DateTime('UTC');
}

/**
* @return array{url: string, headers: array<string,string>}
*/
public function sign(string $path): array
{
$fullpath = sprintf('/%s/%s', $this->bucket, ($this->prefix !== null ? trim($this->prefix, '/') . '/' : '') . $path);
$url = sprintf('https://%s%s', $this->url, $fullpath);

$headers = [
'Host' => $this->url,
'X-Amz-Date' => $this->date->format('Ymd\THis\Z'),
'X-Amz-Content-Sha256' => 'UNSIGNED-PAYLOAD',
];

$headers['Authorization'] = $this->doAuthorization($fullpath, $headers);
$headers['Content-Type'] = 'text/html; charset=utf-8';

return ['url' => $url, 'headers' => $headers];
}


/**
* @param array<string, string> $headers
*/
protected function doAuthorization(string $path, array $headers): string
{
$method = 'PUT';
$query = '';
$payloadHash = 'UNSIGNED-PAYLOAD';
$service = 's3';

$longDate = $this->date->format('Ymd\THis\Z');

// Sort headers by key
$sortedHeaders = $headers;
ksort($sortedHeaders);

// Build headers keys and headers lines
$signedHeaderNames = [];
$signedHeaderLines = [];

foreach ($sortedHeaders as $key => $value) {
$signedHeaderNames[] = strtolower($key);
$signedHeaderLines[] = sprintf('%s:%s', strtolower($key), $value);
}

$signedHeaderLines = implode("\n", $signedHeaderLines);
$signedHeaderNames = implode(';', $signedHeaderNames);

// Scope
$credentialScope = sprintf('%s/%s/%s/aws4_request', $this->date->format('Ymd'), $this->region, $service);

// Canonical
$canonicalRequest = sprintf(
"%s\n%s\n%s\n%s\n\n%s\n%s",
$method,
$path,
$query,
$signedHeaderLines,
$signedHeaderNames,
$payloadHash
);

// Sign string
$hash = hash('sha256', $canonicalRequest);
$stringToSign = sprintf("AWS4-HMAC-SHA256\n%s\n%s\n%s", $longDate, $credentialScope, $hash);

// Sign key
$dateKey = hash_hmac('sha256', $this->date->format('Ymd'), sprintf('AWS4%s', $this->secretKey), true);
$regionKey = hash_hmac('sha256', $this->region, $dateKey, true);
$serviceKey = hash_hmac('sha256', 's3', $regionKey, true);
$signingKey = hash_hmac('sha256', 'aws4_request', $serviceKey, true);
$signature = hash_hmac('sha256', $stringToSign, $signingKey);

// Compute together
return sprintf(
'AWS4-HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s',
$this->accessKey,
$credentialScope,
$signedHeaderNames,
$signature
);
}

}
65 changes: 65 additions & 0 deletions src/Upload/S3Uploader.php
@@ -0,0 +1,65 @@
<?php declare(strict_types = 1);

namespace Contributte\Sentry\Upload;

use Contributte\Sentry\Exception\Runtime\UploadException;
use Throwable;

class S3Uploader
{

/** @var S3Signer */
private $signer;

public function __construct(S3Signer $signer)
{
$this->signer = $signer;
}

/**
* @return array{url: string}
*/
public function upload(string $file): array
{
$filename = basename($file);
$signed = $this->signer->sign($filename);

// Prepare vars
$headers = [];
foreach ($signed['headers'] as $key => $value) {
$headers[] = sprintf('%s:%s', $key, $value);
}

$url = $signed['url'];

// Read file
$content = file_get_contents($file);

try {
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => false,
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 0,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_CUSTOMREQUEST => 'PUT',
CURLOPT_POSTFIELDS => $content,
CURLOPT_HTTPHEADER => $headers,
]);

$response = curl_exec($curl);

curl_close($curl);
} catch (Throwable $e) {
throw new UploadException('Cannot upload', 0, $e);
}

if ($response !== true) {
throw new UploadException('Upload failed');
}

return ['url' => $url];
}

}

0 comments on commit 037f888

Please sign in to comment.