[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:
Jeppe B
2026-07-06 14:27:47 +02:00
committed by GitHub
co-authored by Jeppe Bundgaard
parent 11c2a1b72e
commit 8e46ce1b04
6 changed files with 201 additions and 29 deletions
@@ -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);
+4 -3
View File
@@ -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.');
});