[codex] Allow error reports without screenshots (#299)
* Allow error reports without screenshots * Stabilize edge gateway shell transcript smoke --------- Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
This commit is contained in:
co-authored by
Jeppe Bundgaard
parent
11c2a1b72e
commit
8e46ce1b04
@@ -92,12 +92,17 @@ class error_report_service
|
||||
throw new RuntimeException('Data collection acceptance is required.');
|
||||
}
|
||||
|
||||
$screenshot = self::decodeScreenshotDataUri((string)($payload['screenshot'] ?? ''));
|
||||
$storedScreenshot = $this->store->storeScreenshot($screenshot['mime_type'], $screenshot['contents']);
|
||||
$context = is_array($payload['context'] ?? null) ? $payload['context'] : [];
|
||||
$storedScreenshot = $this->storeOptionalScreenshot($payload['screenshot'] ?? null, $context);
|
||||
$requestErrors = $this->boundedArray($payload['request_errors'] ?? ($context['request_errors'] ?? []), 25);
|
||||
$vueErrors = $this->boundedArray($payload['vue_errors'] ?? ($context['vue_errors'] ?? []), 25);
|
||||
$runtimeContext = $this->runtimeContext($payload, $context);
|
||||
$runtimeContext['screenshot_attachment'] = [
|
||||
'status' => $storedScreenshot['status'],
|
||||
'attached' => $storedScreenshot['key'] !== '',
|
||||
'mime_type' => $storedScreenshot['mime_type'] !== '' ? $storedScreenshot['mime_type'] : null,
|
||||
'size_bytes' => (int)$storedScreenshot['size_bytes'],
|
||||
];
|
||||
|
||||
$this->execute(
|
||||
"INSERT INTO error_reports (
|
||||
@@ -295,6 +300,67 @@ class error_report_service
|
||||
return $value === true || $value === 1 || $value === '1' || $value === 'true';
|
||||
}
|
||||
|
||||
private function storeOptionalScreenshot(mixed $value, array $context): array
|
||||
{
|
||||
if (!is_scalar($value) && !$value instanceof \Stringable && $value !== null) {
|
||||
return $this->emptyScreenshotAttachment('invalid');
|
||||
}
|
||||
|
||||
$dataUri = trim((string)($value ?? ''));
|
||||
if ($dataUri === '') {
|
||||
return $this->emptyScreenshotAttachment($this->contextScreenshotStatus($context) ?? 'not_provided');
|
||||
}
|
||||
|
||||
try {
|
||||
$screenshot = self::decodeScreenshotDataUri($dataUri);
|
||||
} catch (RuntimeException $exception) {
|
||||
$message = strtolower($exception->getMessage());
|
||||
return $this->emptyScreenshotAttachment(str_contains($message, 'too large') ? 'too_large' : 'invalid');
|
||||
}
|
||||
|
||||
try {
|
||||
$storedScreenshot = $this->store->storeScreenshot($screenshot['mime_type'], $screenshot['contents']);
|
||||
} catch (Throwable) {
|
||||
return $this->emptyScreenshotAttachment('storage_failed');
|
||||
}
|
||||
|
||||
return [
|
||||
'key' => (string)($storedScreenshot['key'] ?? ''),
|
||||
'mime_type' => (string)($storedScreenshot['mime_type'] ?? $screenshot['mime_type']),
|
||||
'size_bytes' => (int)($storedScreenshot['size_bytes'] ?? $screenshot['size_bytes']),
|
||||
'status' => 'stored',
|
||||
];
|
||||
}
|
||||
|
||||
private function emptyScreenshotAttachment(string $status): array
|
||||
{
|
||||
return [
|
||||
'key' => '',
|
||||
'mime_type' => '',
|
||||
'size_bytes' => 0,
|
||||
'status' => $status,
|
||||
];
|
||||
}
|
||||
|
||||
private function contextScreenshotStatus(array $context): ?string
|
||||
{
|
||||
$attachment = $context['screenshot_attachment'] ?? null;
|
||||
$status = is_array($attachment) ? ($attachment['status'] ?? null) : null;
|
||||
$status ??= $context['screenshot_capture_status'] ?? $context['screenshot_status'] ?? null;
|
||||
|
||||
return $this->normalizeEmptyScreenshotStatus($status);
|
||||
}
|
||||
|
||||
private function normalizeEmptyScreenshotStatus(mixed $status): ?string
|
||||
{
|
||||
$status = strtolower(trim((string)$status));
|
||||
if (in_array($status, ['capture_failed', 'not_provided'], true)) {
|
||||
return $status;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function runtimeContext(array $payload, array $context): array
|
||||
{
|
||||
return [
|
||||
@@ -432,6 +498,10 @@ class error_report_service
|
||||
|
||||
private function publicReport(array $row, bool $includeDetail): array
|
||||
{
|
||||
$screenshotMimeType = trim((string)($row['screenshot_mime_type'] ?? ''));
|
||||
$screenshotSizeBytes = isset($row['screenshot_size_bytes']) ? (int)$row['screenshot_size_bytes'] : 0;
|
||||
$hasScreenshot = $screenshotMimeType !== '' && $screenshotSizeBytes > 0;
|
||||
|
||||
$report = [
|
||||
'id' => (int)$row['id'],
|
||||
'status' => (string)$row['status'],
|
||||
@@ -449,10 +519,10 @@ class error_report_service
|
||||
'release_trace_id' => $row['release_trace_id'] ?? null,
|
||||
'frontend_version' => $row['frontend_version'] ?? null,
|
||||
'api_version' => $row['api_version'] ?? null,
|
||||
'screenshot' => [
|
||||
'mime_type' => $row['screenshot_mime_type'] ?? null,
|
||||
'size_bytes' => isset($row['screenshot_size_bytes']) ? (int)$row['screenshot_size_bytes'] : 0,
|
||||
],
|
||||
'screenshot' => $hasScreenshot ? [
|
||||
'mime_type' => $screenshotMimeType,
|
||||
'size_bytes' => $screenshotSizeBytes,
|
||||
] : null,
|
||||
'answers' => [
|
||||
'before_error' => $row['before_error'] ?? '',
|
||||
'expected' => $row['expected'] ?? '',
|
||||
@@ -467,8 +537,11 @@ class error_report_service
|
||||
];
|
||||
|
||||
if ($includeDetail) {
|
||||
$report['screenshot']['url'] = $this->store->screenshotUrl((string)($row['screenshot_object_key'] ?? ''));
|
||||
$report['screenshot']['object_key'] = $row['screenshot_object_key'] ?? null;
|
||||
if ($hasScreenshot) {
|
||||
$objectKey = trim((string)($row['screenshot_object_key'] ?? ''));
|
||||
$report['screenshot']['url'] = $this->store->screenshotUrl($objectKey);
|
||||
$report['screenshot']['object_key'] = $objectKey !== '' ? $objectKey : null;
|
||||
}
|
||||
$report['request_errors'] = $this->jsonDecode($row['request_errors_json'] ?? null);
|
||||
$report['vue_errors'] = $this->jsonDecode($row['vue_errors_json'] ?? null);
|
||||
$report['runtime_context'] = $this->jsonDecode($row['runtime_context_json'] ?? null);
|
||||
|
||||
@@ -13388,7 +13388,6 @@ components:
|
||||
- expected
|
||||
- actual
|
||||
- data_collection_accepted
|
||||
- screenshot
|
||||
properties:
|
||||
before_error:
|
||||
type: string
|
||||
@@ -13404,10 +13403,11 @@ components:
|
||||
description: What actually happened
|
||||
data_collection_accepted:
|
||||
type: boolean
|
||||
description: Required acceptance of collecting screenshot and diagnostic error data
|
||||
description: Required acceptance of collecting diagnostic error data and a screenshot when one can be attached
|
||||
screenshot:
|
||||
type: string
|
||||
description: PNG, JPEG, or WebP data URI of the current app viewport
|
||||
nullable: true
|
||||
description: Optional PNG, JPEG, or WebP data URI of the current app viewport. Reports are accepted without an attachment when capture or upload fails.
|
||||
route_path:
|
||||
type: string
|
||||
nullable: true
|
||||
@@ -13518,6 +13518,7 @@ components:
|
||||
nullable: true
|
||||
screenshot:
|
||||
type: object
|
||||
nullable: true
|
||||
additionalProperties: true
|
||||
answers:
|
||||
type: object
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
usesApiSuite();
|
||||
|
||||
function error_report_api_payload(array $overrides = []): array
|
||||
{
|
||||
return array_replace_recursive([
|
||||
'before_error' => 'Opening the orders page',
|
||||
'expected' => 'The orders should load',
|
||||
'actual' => 'The page showed an error',
|
||||
'data_collection_accepted' => true,
|
||||
'data_collection_policy_version' => 'error-report-v1',
|
||||
'route_path' => '/admin/orders',
|
||||
'page_url' => 'https://app.example.test/admin/orders',
|
||||
'release_trace_id' => 'trace-error-report-test',
|
||||
'frontend_version' => 'frontend-test',
|
||||
'api_version' => 'api-test',
|
||||
'request_errors' => [
|
||||
['method' => 'GET', 'url' => '/orders', 'statusCode' => 500],
|
||||
],
|
||||
'vue_errors' => [
|
||||
['type' => 'vue_component_error', 'payload' => ['message' => 'Render failed']],
|
||||
],
|
||||
'context' => [
|
||||
'viewport' => ['width' => 1280, 'height' => 720],
|
||||
'user_agent' => 'ErrorReportsApiTest',
|
||||
'captured_at' => '2026-07-06T10:00:00.000Z',
|
||||
'data_collection_policy_version' => 'error-report-v1',
|
||||
],
|
||||
], $overrides);
|
||||
}
|
||||
|
||||
function error_report_api_cleanup(array $report): void
|
||||
{
|
||||
$id = (int)($report['id'] ?? 0);
|
||||
if ($id > 0) {
|
||||
api_fixtures()->cleanupDeleteById('error_reports', $id);
|
||||
}
|
||||
}
|
||||
|
||||
it('creates error reports when screenshot capture failed', function (): void {
|
||||
api_test_covers('POST /error-reports', 'happy');
|
||||
|
||||
$session = api_fixtures()->createUserSession();
|
||||
$response = api_client()->post('/error-reports', error_report_api_payload([
|
||||
'screenshot' => null,
|
||||
'context' => [
|
||||
'screenshot_attachment' => ['status' => 'capture_failed'],
|
||||
],
|
||||
]), $session['headers']);
|
||||
|
||||
$response
|
||||
->assertStatus(201)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$report = $response->data();
|
||||
expect($report['screenshot'])->toBeNull();
|
||||
expect($report['answers']['before_error'])->toBe('Opening the orders page');
|
||||
expect($report['request_error_count'])->toBe(1);
|
||||
expect($report['vue_error_count'])->toBe(1);
|
||||
expect($report['runtime_context']['screenshot_attachment'])->toMatchArray([
|
||||
'status' => 'capture_failed',
|
||||
'attached' => false,
|
||||
'mime_type' => null,
|
||||
'size_bytes' => 0,
|
||||
]);
|
||||
|
||||
error_report_api_cleanup($report);
|
||||
});
|
||||
|
||||
it('creates error reports when an optional screenshot payload is invalid', function (): void {
|
||||
api_test_covers('POST /error-reports', 'invalid optional screenshot');
|
||||
|
||||
$session = api_fixtures()->createUserSession();
|
||||
$response = api_client()->post('/error-reports', error_report_api_payload([
|
||||
'screenshot' => 'data:text/plain;base64,' . base64_encode('not an image'),
|
||||
]), $session['headers']);
|
||||
|
||||
$response
|
||||
->assertStatus(201)
|
||||
->assertEnvelope()
|
||||
->assertSuccess();
|
||||
|
||||
$report = $response->data();
|
||||
expect($report['screenshot'])->toBeNull();
|
||||
expect($report['runtime_context']['screenshot_attachment'])->toMatchArray([
|
||||
'status' => 'invalid',
|
||||
'attached' => false,
|
||||
'mime_type' => null,
|
||||
'size_bytes' => 0,
|
||||
]);
|
||||
|
||||
error_report_api_cleanup($report);
|
||||
});
|
||||
@@ -76,4 +76,6 @@ it('defines error report schema, routes, permissions, storage, and OpenAPI docs'
|
||||
expect($openapi)->toContain('/error-reports:');
|
||||
expect($openapi)->toContain('ErrorReportSubmissionRequest');
|
||||
expect($openapi)->toContain('ErrorReportStatusUpdateRequest');
|
||||
expect($openapi)->not->toContain(" - screenshot\n");
|
||||
expect($openapi)->toContain('Reports are accepted without an attachment when capture or upload fails.');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user