diff --git a/src/DataCollection/DataCollectionOptions.php b/src/DataCollection/DataCollectionOptions.php new file mode 100644 index 000000000..3902ec614 --- /dev/null +++ b/src/DataCollection/DataCollectionOptions.php @@ -0,0 +1,338 @@ +, + * value-of + * > + * + * @mago-ignore analysis:missing-template-parameter + */ +final class DataCollectionOptions implements \ArrayAccess +{ + private const COLLECTION_MODES = [ + 'off', + 'denyList', + 'allowList', + ]; + + private const COLLECTION_DEFAULT = [ + 'mode' => 'denyList', + 'terms' => [], + ]; + + /** + * @internal + */ + public const HTTP_BODY_TYPES = [ + 'incomingRequest', + 'outgoingRequest', + 'incomingResponse', + 'outgoingResponse', + ]; + + private const DEFAULTS = [ + 'user_info' => true, + 'cookies' => self::COLLECTION_DEFAULT, + 'http_headers' => [ + 'request' => self::COLLECTION_DEFAULT, + 'response' => self::COLLECTION_DEFAULT, + ], + 'http_bodies' => self::HTTP_BODY_TYPES, + 'query_params' => self::COLLECTION_DEFAULT, + 'gen_ai' => [ + 'inputs' => true, + 'outputs' => true, + ], + 'stack_frame_variables' => true, + 'frame_context_lines' => 5, + ]; + + /** + * @var array + * + * @phpstan-var ResolvedDataCollectionOptions + */ + private $options; + + /** + * @var OptionsResolver + */ + private $resolver; + + /** + * @param array $options + */ + public function __construct(array $options = []) + { + $this->resolver = new OptionsResolver(); + $this->configureOptions($this->resolver); + + /** @var ResolvedDataCollectionOptions $resolvedOptions */ + $resolvedOptions = $this->resolver->resolve($options); + $this->options = $resolvedOptions; + } + + public function shouldCollectUserInfo(): bool + { + return $this->options['user_info']; + } + + public function setUserInfo(bool $userInfo): self + { + return $this->updateOptions(['user_info' => $userInfo]); + } + + /** + * @phpstan-return KeyValueCollectionBehavior + */ + public function getCookies(): array + { + return $this->options['cookies']; + } + + /** + * @param array $cookies + * + * @phpstan-param array{mode?: 'off'|'denyList'|'allowList', terms?: string[]} $cookies + */ + public function setCookies(array $cookies): self + { + return $this->updateOptions(['cookies' => $cookies]); + } + + /** + * @phpstan-return HttpHeaders + */ + public function getHttpHeaders(): array + { + return $this->options['http_headers']; + } + + /** + * @param array $httpHeaders + * + * @phpstan-param array{ + * mode?: 'off'|'denyList'|'allowList', + * terms?: string[], + * request?: array{mode?: 'off'|'denyList'|'allowList', terms?: string[]}, + * response?: array{mode?: 'off'|'denyList'|'allowList', terms?: string[]} + * } $httpHeaders + */ + public function setHttpHeaders(array $httpHeaders): self + { + return $this->updateOptions(['http_headers' => $httpHeaders]); + } + + /** + * @return string[] + */ + public function getHttpBodies(): array + { + return $this->options['http_bodies']; + } + + /** + * @param string[] $httpBodies + */ + public function setHttpBodies(array $httpBodies): self + { + return $this->updateOptions(['http_bodies' => $httpBodies]); + } + + /** + * @phpstan-return KeyValueCollectionBehavior + */ + public function getQueryParams(): array + { + return $this->options['query_params']; + } + + /** + * @param array $queryParams + * + * @phpstan-param array{mode?: 'off'|'denyList'|'allowList', terms?: string[]} $queryParams + */ + public function setQueryParams(array $queryParams): self + { + return $this->updateOptions(['query_params' => $queryParams]); + } + + /** + * @phpstan-return GenAi + */ + public function getGenAi(): array + { + return $this->options['gen_ai']; + } + + /** + * @param array $genAi + * + * @phpstan-param array{inputs?: bool, outputs?: bool} $genAi + */ + public function setGenAi(array $genAi): self + { + return $this->updateOptions(['gen_ai' => $genAi]); + } + + public function shouldCollectStackFrameVariables(): bool + { + return $this->options['stack_frame_variables']; + } + + public function setStackFrameVariables(bool $stackFrameVariables): self + { + return $this->updateOptions(['stack_frame_variables' => $stackFrameVariables]); + } + + public function getFrameContextLines(): int + { + return $this->options['frame_context_lines']; + } + + public function setFrameContextLines(int $frameContextLines): self + { + return $this->updateOptions(['frame_context_lines' => $frameContextLines]); + } + + /** + * @param mixed $offset + */ + public function offsetExists($offset): bool + { + return \is_string($offset) && \array_key_exists($offset, $this->options); + } + + /** + * @phpstan-template TKey of key-of + * + * @param mixed $offset + * + * @phpstan-param TKey $offset + * + * @return mixed + * + * @phpstan-return ResolvedDataCollectionOptions[TKey] + * + * @mago-ignore analysis:incompatible-parameter-type + * @mago-ignore analysis:invalid-return-statement + */ + #[\ReturnTypeWillChange] + public function offsetGet($offset) + { + if (!$this->offsetExists($offset)) { + /** @phpstan-ignore-next-line Runtime access to unknown offsets is intentionally non-throwing. */ + return null; + } + + return $this->options[$offset]; + } + + /** + * @param mixed $offset + * @param mixed $value + */ + public function offsetSet($offset, $value): void + { + if (!\is_string($offset)) { + return; + } + + $this->updateOptions([$offset => $value]); + } + + /** + * @param mixed $offset + */ + public function offsetUnset($offset): void + { + if (!\is_string($offset) || !\array_key_exists($offset, self::DEFAULTS)) { + return; + } + + /** @var mixed $default */ + $default = self::DEFAULTS[$offset]; + $this->updateOptions([$offset => $default]); + } + + private function configureOptions(OptionsResolver $resolver): void + { + $resolver->setAllowedTypes('user_info', 'bool'); + $resolver->setAllowedTypes('cookies', 'array'); + $resolver->setAllowedTypes('cookies.mode', 'string'); + $resolver->setAllowedTypes('cookies.terms', 'string[]'); + $resolver->setAllowedTypes('http_headers', 'array'); + $resolver->setAllowedTypes('http_headers.request', 'array'); + $resolver->setAllowedTypes('http_headers.request.mode', 'string'); + $resolver->setAllowedTypes('http_headers.request.terms', 'string[]'); + $resolver->setAllowedTypes('http_headers.response', 'array'); + $resolver->setAllowedTypes('http_headers.response.mode', 'string'); + $resolver->setAllowedTypes('http_headers.response.terms', 'string[]'); + $resolver->setAllowedTypes('http_bodies', 'string[]'); + $resolver->setAllowedTypes('query_params', 'array'); + $resolver->setAllowedTypes('query_params.mode', 'string'); + $resolver->setAllowedTypes('query_params.terms', 'string[]'); + $resolver->setAllowedTypes('gen_ai', 'array'); + $resolver->setAllowedTypes('gen_ai.inputs', 'bool'); + $resolver->setAllowedTypes('gen_ai.outputs', 'bool'); + $resolver->setAllowedTypes('stack_frame_variables', 'bool'); + $resolver->setAllowedTypes('frame_context_lines', 'int'); + + $resolver->setAllowedValues('cookies.mode', self::COLLECTION_MODES); + $resolver->setAllowedValues('http_headers.request.mode', self::COLLECTION_MODES); + $resolver->setAllowedValues('http_headers.response.mode', self::COLLECTION_MODES); + $resolver->setAllowedValues('query_params.mode', self::COLLECTION_MODES); + $resolver->setAllowedValues('http_bodies', static function (array $value): bool { + return array_diff($value, self::HTTP_BODY_TYPES) === []; + }); + $resolver->setAllowedValues('frame_context_lines', static function (int $value): bool { + return $value >= 0; + }); + + $resolver->setNormalizer('http_headers', static function (array $value): array { + if (!\array_key_exists('request', $value) && !\array_key_exists('response', $value)) { + return [ + 'request' => $value, + 'response' => $value, + ]; + } + + return $value; + }); + $resolver->setDefaults(self::DEFAULTS); + } + + /** + * @param array $override + */ + private function updateOptions(array $override): self + { + $resolved = $this->resolver->resolveOnly($override, $this->options); + /** @var ResolvedDataCollectionOptions $options */ + $options = array_merge($this->options, $resolved); + $this->options = $options; + + return $this; + } +} diff --git a/src/Options.php b/src/Options.php index 73d8ffad7..03e770271 100644 --- a/src/Options.php +++ b/src/Options.php @@ -6,6 +6,7 @@ use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; +use Sentry\DataCollection\DataCollectionOptions; use Sentry\HttpClient\HttpClientInterface; use Sentry\Integration\ErrorListenerIntegration; use Sentry\Integration\IntegrationInterface; @@ -351,6 +352,14 @@ public function setContextLines(?int $contextLines): self return $this->updateOptions(['context_lines' => $contextLines]); } + public function getDataCollection(): DataCollectionOptions + { + /** @var DataCollectionOptions $dataCollection */ + $dataCollection = $this->options['data_collection']; + + return $dataCollection; + } + /** * Gets the environment. */ @@ -1258,6 +1267,7 @@ private function configureOptions(OptionsResolver $resolver): void $resolver->setAllowedTypes('capture_silenced_errors', 'bool'); $resolver->setAllowedTypes('max_request_body_size', 'string'); $resolver->setAllowedTypes('class_serializers', 'array'); + $resolver->setAllowedTypes('data_collection', ['array', DataCollectionOptions::class]); $resolver->setAllowedValues('max_request_body_size', ['none', 'never', 'small', 'medium', 'always']); $resolver->setAllowedValues('dsn', \Closure::fromCallable([$this, 'validateDsnOption'])); @@ -1268,6 +1278,7 @@ private function configureOptions(OptionsResolver $resolver): void $resolver->setAllowedValues('metric_flush_threshold', \Closure::fromCallable([$this, 'validateMetricFlushThresholdOption'])); $resolver->setNormalizer('dsn', \Closure::fromCallable([$this, 'normalizeDsnOption'])); + $resolver->setNormalizer('data_collection', \Closure::fromCallable([$this, 'normalizeDataCollectionOption'])); $resolver->setNormalizer('prefixes', function (array $value) { return array_map([$this, 'normalizeAbsolutePath'], $value); @@ -1365,6 +1376,7 @@ private function configureOptions(OptionsResolver $resolver): void 'capture_silenced_errors' => false, 'max_request_body_size' => 'medium', 'class_serializers' => [], + 'data_collection' => new DataCollectionOptions(), ]); } @@ -1414,6 +1426,18 @@ private function normalizeSpotlightUrl(string $url): string return $url; } + /** + * @param array|DataCollectionOptions $value + */ + private function normalizeDataCollectionOption($value): DataCollectionOptions + { + if ($value instanceof DataCollectionOptions) { + return $value; + } + + return new DataCollectionOptions($value); + } + /** * Normalizes the DSN option by parsing the host, public and secret keys and * an optional path. diff --git a/src/OptionsResolver.php b/src/OptionsResolver.php index 6dad26e3b..465a51d2b 100644 --- a/src/OptionsResolver.php +++ b/src/OptionsResolver.php @@ -225,8 +225,10 @@ private function applyOptions( continue; } + /** @mago-ignore analysis:mixed-assignment */ $base = $resolved[$option] ?? null; if (!\is_array($base)) { + /** @mago-ignore analysis:mixed-assignment */ $base = $default; } diff --git a/src/functions.php b/src/functions.php index ecbc8a349..e5e0e58a7 100644 --- a/src/functions.php +++ b/src/functions.php @@ -31,6 +31,19 @@ * before_send_transaction?: callable, * capture_silenced_errors?: bool, * context_lines?: int|null, + * data_collection?: DataCollection\DataCollectionOptions|array{ + * user_info?: bool, + * cookies?: array{mode?: "off"|"denyList"|"allowList", terms?: array}, + * http_headers?: array{mode?: "off"|"denyList"|"allowList", terms?: array}|array{ + * request?: array{mode?: "off"|"denyList"|"allowList", terms?: array}, + * response?: array{mode?: "off"|"denyList"|"allowList", terms?: array}, + * }, + * http_bodies?: array<"incomingRequest"|"outgoingRequest"|"incomingResponse"|"outgoingResponse">, + * query_params?: array{mode?: "off"|"denyList"|"allowList", terms?: array}, + * gen_ai?: array{inputs?: bool, outputs?: bool}, + * stack_frame_variables?: bool, + * frame_context_lines?: int, + * }, * default_integrations?: bool, * dsn?: string|bool|Dsn|null, * enable_logs?: bool, diff --git a/tests/DataCollection/DataCollectionOptionsTest.php b/tests/DataCollection/DataCollectionOptionsTest.php new file mode 100644 index 000000000..1df694aa2 --- /dev/null +++ b/tests/DataCollection/DataCollectionOptionsTest.php @@ -0,0 +1,135 @@ + 'denyList', 'terms' => []]; + + $this->assertTrue($options->shouldCollectUserInfo()); + $this->assertSame($collectionDefault, $options->getCookies()); + $this->assertSame([ + 'request' => $collectionDefault, + 'response' => $collectionDefault, + ], $options->getHttpHeaders()); + $this->assertSame(DataCollectionOptions::HTTP_BODY_TYPES, $options->getHttpBodies()); + $this->assertSame($collectionDefault, $options->getQueryParams()); + $this->assertSame(['inputs' => true, 'outputs' => true], $options->getGenAi()); + $this->assertTrue($options->shouldCollectStackFrameVariables()); + $this->assertSame(5, $options->getFrameContextLines()); + } + + public function testSharedHttpHeadersConfigurationAppliesToBothDirections(): void + { + $options = new DataCollectionOptions([ + 'http_headers' => [ + 'mode' => 'allowList', + 'terms' => ['x-request-id'], + ], + ]); + + $expected = ['mode' => 'allowList', 'terms' => ['x-request-id']]; + $this->assertSame(['request' => $expected, 'response' => $expected], $options->getHttpHeaders()); + } + + public function testSetterPreservesUnchangedNestedValues(): void + { + $options = new DataCollectionOptions([ + 'cookies' => ['mode' => 'allowList', 'terms' => ['first']], + ]); + + $result = $options->setCookies(['terms' => ['second']]); + + $this->assertSame($options, $result); + $this->assertSame(['mode' => 'allowList', 'terms' => ['second']], $options->getCookies()); + } + + public function testNullHttpBodiesUsesDefault(): void + { + $options = new DataCollectionOptions(['http_bodies' => null]); + + $this->assertSame(DataCollectionOptions::HTTP_BODY_TYPES, $options->getHttpBodies()); + } + + public function testInvalidValuesUseDefaultsAndSettersKeepCurrentValues(): void + { + $options = new DataCollectionOptions([ + 'cookies' => ['mode' => 'invalid', 'terms' => [42]], + 'http_bodies' => ['invalid'], + 'gen_ai' => ['inputs' => 'invalid'], + 'frame_context_lines' => -1, + ]); + + $this->assertSame(['mode' => 'denyList', 'terms' => []], $options->getCookies()); + $this->assertSame(DataCollectionOptions::HTTP_BODY_TYPES, $options->getHttpBodies()); + $this->assertSame(['inputs' => true, 'outputs' => true], $options->getGenAi()); + $this->assertSame(5, $options->getFrameContextLines()); + + $options->setCookies(['mode' => 'allowList'])->setCookies(['mode' => 'invalid']); + $options->setHttpBodies(['incomingRequest'])->setHttpBodies(['invalid']); + $options->setFrameContextLines(2)->setFrameContextLines(-1); + + $this->assertSame('allowList', $options->getCookies()['mode']); + $this->assertSame(['incomingRequest'], $options->getHttpBodies()); + $this->assertSame(2, $options->getFrameContextLines()); + } + + public function testArrayAccessReadsNestedOptions(): void + { + $options = new DataCollectionOptions([ + 'http_headers' => [ + 'request' => ['mode' => 'allowList'], + ], + ]); + + $this->assertTrue(isset($options['http_headers'])); + $this->assertFalse(isset($options['unknown'])); + $this->assertSame('allowList', $options['http_headers']['request']['mode']); + $this->assertNull($options['unknown']); + $this->assertNull($options[0]); + } + + public function testArrayAccessWritesUseResolver(): void + { + $options = new DataCollectionOptions(); + + $options['http_headers'] = [ + 'request' => ['mode' => 'off'], + ]; + $this->assertSame('off', $options['http_headers']['request']['mode']); + $this->assertSame('denyList', $options['http_headers']['response']['mode']); + + $options['http_headers'] = ['request' => ['mode' => 'invalid']]; + $options['frame_context_lines'] = -1; + $options['http_bodies'] = ['incomingRequest']; + $options['http_bodies'] = null; + $options['unknown'] = true; + $options[] = true; + + $this->assertSame('off', $options['http_headers']['request']['mode']); + $this->assertSame(5, $options['frame_context_lines']); + $this->assertSame(['incomingRequest'], $options['http_bodies']); + $this->assertNull($options['unknown']); + } + + public function testArrayAccessUnsetRestoresDefault(): void + { + $options = new DataCollectionOptions([ + 'user_info' => false, + 'http_bodies' => [], + ]); + + unset($options['user_info'], $options['http_bodies'], $options['unknown'], $options[0]); + + $this->assertTrue($options['user_info']); + $this->assertSame(DataCollectionOptions::HTTP_BODY_TYPES, $options['http_bodies']); + } +} diff --git a/tests/OptionsTest.php b/tests/OptionsTest.php index 32972eefb..fada65cbe 100644 --- a/tests/OptionsTest.php +++ b/tests/OptionsTest.php @@ -7,6 +7,7 @@ use PHPUnit\Framework\TestCase; use Psr\Log\NullLogger; use Sentry\ClientBuilder; +use Sentry\DataCollection\DataCollectionOptions; use Sentry\Dsn; use Sentry\Event; use Sentry\HttpClient\HttpClient; @@ -376,6 +377,13 @@ static function (): void {}, 'setSendDefaultPii', ]; + yield [ + 'option' => 'data_collection', + 'value' => (new DataCollectionOptions())->setUserInfo(false), + 'getter' => 'getDataCollection', + 'setter' => null, + ]; + yield [ 'default_integrations', false, @@ -524,7 +532,7 @@ static function (array $integrations): array { public static function optionsWithSettersDataProvider(): \Generator { foreach (self::optionsDataProvider() as $testCase) { - if ($testCase[3] !== null) { + if (($testCase['setter'] ?? $testCase[3] ?? null) !== null) { yield $testCase; } } @@ -536,7 +544,7 @@ public function testAllOptionsAreCoveredByOptionsDataProvider(): void $testedOptions = []; foreach (self::optionsDataProvider() as $testCase) { - $testedOptions[] = $testCase[0]; + $testedOptions[] = $testCase['option'] ?? $testCase[0]; } $testedOptions = array_values(array_unique($testedOptions)); @@ -582,6 +590,9 @@ public function testDefaultOptionValues(): void $actual[$callbackOption] = \Closure::class; } + $this->assertInstanceOf(DataCollectionOptions::class, $actual['data_collection']); + $actual['data_collection'] = DataCollectionOptions::class; + $expected = [ 'integrations' => [], 'default_integrations' => true, @@ -625,6 +636,7 @@ public function testDefaultOptionValues(): void 'in_app_exclude' => [], 'in_app_include' => [], 'send_default_pii' => false, + 'data_collection' => DataCollectionOptions::class, 'max_value_length' => 1024, 'transport' => null, 'http_client' => null, @@ -669,6 +681,34 @@ public function testAllDefaultValuesPassValidation(): void $this->assertSame([], StubLogger::$logs); } + public function testDataCollectionOptionNormalizesNestedArray(): void + { + $dataCollection = (new Options([ + 'data_collection' => [ + 'user_info' => false, + 'http_headers' => [ + 'request' => ['mode' => 'off'], + ], + 'gen_ai' => ['outputs' => false], + ], + ]))->getDataCollection(); + + $this->assertFalse($dataCollection->shouldCollectUserInfo()); + $this->assertSame('off', $dataCollection->getHttpHeaders()['request']['mode']); + $this->assertSame('denyList', $dataCollection->getHttpHeaders()['response']['mode']); + $this->assertSame(['inputs' => true, 'outputs' => false], $dataCollection->getGenAi()); + } + + public function testDataCollectionOptionPreservesObjectIdentityAndCanBeUpdatedThroughGetter(): void + { + $dataCollection = (new DataCollectionOptions())->setUserInfo(false); + $options = new Options(['data_collection' => $dataCollection]); + + $this->assertSame($dataCollection, $options->getDataCollection()); + $options->getDataCollection()->setFrameContextLines(0); + $this->assertSame(0, $dataCollection->getFrameContextLines()); + } + /** * @dataProvider dsnOptionDataProvider */