From 1f05526cc5c25548283bd018020e899e58fa272e Mon Sep 17 00:00:00 2001 From: Josiah King Date: Wed, 16 Sep 2026 13:47:53 +0100 Subject: [PATCH] Add Insight diagnostic capture policy --- CHANGELOG.md | 3 +- packages/insight/README.md | 17 +- .../src/Capture/DefaultDiagnosticRedactor.php | 69 ++++ .../DeterministicDiagnosticSampler.php | 34 ++ .../src/Capture/DiagnosticAttribute.php | 53 +++ .../src/Capture/DiagnosticCaptureFilter.php | 41 +++ .../src/Capture/DiagnosticCapturePolicy.php | 116 +++++++ .../Capture/DiagnosticDataClassification.php | 16 + .../insight/src/Capture/DiagnosticEntry.php | 75 ++++ .../src/Capture/DiagnosticRedactor.php | 10 + .../Capture/DefaultDiagnosticRedactorTest.php | 77 +++++ .../DeterministicDiagnosticSamplerTest.php | 76 ++++ .../Capture/DiagnosticCaptureFilterTest.php | 56 +++ .../Capture/DiagnosticCapturePolicyTest.php | 325 ++++++++++++++++++ .../EvolvePhp2PackageSkeletonTest.php | 8 + 15 files changed, 974 insertions(+), 2 deletions(-) create mode 100644 packages/insight/src/Capture/DefaultDiagnosticRedactor.php create mode 100644 packages/insight/src/Capture/DeterministicDiagnosticSampler.php create mode 100644 packages/insight/src/Capture/DiagnosticAttribute.php create mode 100644 packages/insight/src/Capture/DiagnosticCaptureFilter.php create mode 100644 packages/insight/src/Capture/DiagnosticCapturePolicy.php create mode 100644 packages/insight/src/Capture/DiagnosticDataClassification.php create mode 100644 packages/insight/src/Capture/DiagnosticEntry.php create mode 100644 packages/insight/src/Capture/DiagnosticRedactor.php create mode 100644 packages/insight/tests/Unit/Capture/DefaultDiagnosticRedactorTest.php create mode 100644 packages/insight/tests/Unit/Capture/DeterministicDiagnosticSamplerTest.php create mode 100644 packages/insight/tests/Unit/Capture/DiagnosticCaptureFilterTest.php create mode 100644 packages/insight/tests/Unit/Capture/DiagnosticCapturePolicyTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b8940f..18e0fd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,11 @@ ### Insight -- Added `evolvephp/insight` as a public experimental diagnostic-batch collection foundation. The package consumes Core execution observations through the existing observation sink boundary, collects immutable bounded per-execution batches and keeps redaction, dashboards, watchers, OpenTelemetry, Evolve Observe, runtime wiring and independent package release deferred. +- Added `evolvephp/insight` as a public experimental diagnostic-batch collection foundation. The package consumes Core execution observations through the existing observation sink boundary, collects immutable bounded per-execution batches and keeps dashboards, watchers, OpenTelemetry, Evolve Observe, runtime wiring and independent package release deferred. - Added storage-neutral Insight batch snapshots, projection from finalized diagnostic batches, a minimal diagnostic batch store contract, an in-memory store for tests and short-lived local development, and a sink adapter that projects and stores accepted batches while keeping runtime wiring deferred. - Added a versioned primitive diagnostic batch snapshot codec and an optional SQLite diagnostic batch store for explicit local-development persistence through caller-supplied SQLite `PDO` connections, while keeping application database integration deferred. - Added explicit count-bounded retention to the first-party Insight diagnostic batch stores, with deterministic oldest-first pruning for in-memory and SQLite storage. +- Added a diagnostic capture-policy foundation with primitive-only entries, explicit data classifications, deterministic redaction, exact category/name filtering and deterministic execution-level sampling, while keeping rich watchers, runtime wiring and storage integration deferred. ### Repository diff --git a/packages/insight/README.md b/packages/insight/README.md index 6654c2c..ae62698 100644 --- a/packages/insight/README.md +++ b/packages/insight/README.md @@ -10,6 +10,8 @@ Evolve Insight consumes safe Core execution observations and collects them into Insight also provides a storage-neutral projection boundary for finalized diagnostic batches. `DiagnosticBatchProjector` detaches a `DiagnosticBatch` into a primitive-only `DiagnosticBatchSnapshot` made of string-backed execution identity, execution kind, ordered `DiagnosticObservationSnapshot` values and the dropped observation count. Snapshots do not retain Core `Observation`, execution identifier, request, response, container, throwable or execution-scope objects. +Insight includes a detached diagnostic capture-policy foundation for future rich diagnostic sources. `DiagnosticEntry` and `DiagnosticAttribute` represent bounded primitive-only diagnostic data: execution identifier value, diagnostic category, diagnostic name and ordered attributes whose values are limited to `string`, `int`, `float`, `bool` or `null`. Attribute names, entry identifiers, categories, names and string values are bounded, and non-finite floats, arrays, objects, resources and callables are not accepted. + Current bounded behavior: - collection starts only after Core reports an execution start @@ -36,6 +38,19 @@ The SQLite store creates its diagnostic table only when explicitly constructed w `pdo_sqlite` is a runtime requirement only for applications that explicitly use `SqliteDiagnosticBatchStore`; it is not required for installing or using the non-SQLite Insight functionality. +Current capture-policy behavior: + +- `DiagnosticDataClassification` provides explicit machine-readable classifications for public operational metadata, internal operational metadata, personal data, authentication data, secret data, business-sensitive payloads and regulated data +- `DiagnosticCapturePolicy` accepts public and internal operational metadata by default; personal, business-sensitive and regulated data are excluded unless explicitly enabled by application code +- secret and authentication data are never deliberately accepted raw +- `DefaultDiagnosticRedactor` deterministically suppresses secret and authentication attributes, and replaces common sensitive operational machine names such as authorization, password, cookies, tokens, API keys, secrets and session identifiers with `[REDACTED]` +- `DiagnosticCaptureFilter` supports exact category and diagnostic-name disabling for volume control only +- `DeterministicDiagnosticSampler` supports integer percentage sampling from 0 to 100 using a stable hash of the execution identifier +- sampling controls diagnostic volume only; it is not authorization, authentication, security enforcement, redaction, legal retention or an error-retention guarantee +- accepted attributes keep their original order, use first-accepted-wins duplicate-name handling and are capped by a deterministic retained-attribute limit + +The capture-policy foundation does not add rich HTTP, database, cache, log, event, queue or other watchers. Capture entries are not integrated into diagnostic batches, storage snapshots, SQLite payloads or automatic runtime wiring. + ## Requirements PHP `^8.4` @@ -52,7 +67,7 @@ https://github.com/josiahking/evolvephp ## Current Limitations -This package does not provide time-based retention, redaction, rich diagnostic capture, filtering, sampling, dashboards, watchers, OpenTelemetry, Evolve Observe, trace propagation, runtime composition, automatic registration, application database integration or production-ready diagnostics. +This package does not provide time-based retention, rich HTTP/database/cache/log/event/queue diagnostic watchers, dashboards, OpenTelemetry, Evolve Observe, trace propagation, runtime composition, automatic registration, application database integration, production telemetry export or production-ready diagnostics. ## Licence diff --git a/packages/insight/src/Capture/DefaultDiagnosticRedactor.php b/packages/insight/src/Capture/DefaultDiagnosticRedactor.php new file mode 100644 index 0000000..d55de81 --- /dev/null +++ b/packages/insight/src/Capture/DefaultDiagnosticRedactor.php @@ -0,0 +1,69 @@ +classification() === DiagnosticDataClassification::SecretData + || $attribute->classification() === DiagnosticDataClassification::AuthenticationData + ) { + return null; + } + + if ($this->isOperational($attribute) && $this->isSensitiveMachineName($attribute->name())) { + return new DiagnosticAttribute( + $attribute->name(), + $attribute->classification(), + self::REDACTION_MARKER, + ); + } + + return $attribute; + } + + private function isOperational(DiagnosticAttribute $attribute): bool + { + return $attribute->classification() === DiagnosticDataClassification::PublicOperationalMetadata + || $attribute->classification() === DiagnosticDataClassification::InternalOperationalMetadata; + } + + private function isSensitiveMachineName(string $name): bool + { + $segments = array_values(array_filter( + preg_split('/[._\\-\\s\\/]+/', strtolower($name)) ?: array(), + static fn (string $segment): bool => $segment !== '', + )); + + foreach ($segments as $segment) { + if (in_array($segment, array('authorization', 'authentication', 'password', 'passwd', 'cookie', 'secret', 'session'), true)) { + return true; + } + } + + $pairs = array( + array('set', 'cookie'), + array('access', 'token'), + array('refresh', 'token'), + array('api', 'key'), + array('session', 'id'), + array('session', 'identifier'), + ); + + foreach ($pairs as $pair) { + for ($index = 0, $count = count($segments) - 1; $index < $count; $index++) { + if ($segments[$index] === $pair[0] && $segments[$index + 1] === $pair[1]) { + return true; + } + } + } + + return false; + } +} diff --git a/packages/insight/src/Capture/DeterministicDiagnosticSampler.php b/packages/insight/src/Capture/DeterministicDiagnosticSampler.php new file mode 100644 index 0000000..120886c --- /dev/null +++ b/packages/insight/src/Capture/DeterministicDiagnosticSampler.php @@ -0,0 +1,34 @@ +percentage < 0 || $this->percentage > 100) { + throw new \InvalidArgumentException('Diagnostic sampler percentage must be between 0 and 100.'); + } + } + + public function accepts(string $executionIdentifier): bool + { + DiagnosticAttribute::assertBoundedNonEmptyString( + $executionIdentifier, + 'Diagnostic execution identifier', + DiagnosticEntry::MAX_EXECUTION_IDENTIFIER_LENGTH, + ); + + if ($this->percentage === 0) { + return false; + } + + if ($this->percentage === 100) { + return true; + } + + return ((int) sprintf('%u', crc32($executionIdentifier)) % 100) < $this->percentage; + } +} diff --git a/packages/insight/src/Capture/DiagnosticAttribute.php b/packages/insight/src/Capture/DiagnosticAttribute.php new file mode 100644 index 0000000..ca059e0 --- /dev/null +++ b/packages/insight/src/Capture/DiagnosticAttribute.php @@ -0,0 +1,53 @@ +name, 'Diagnostic attribute name', self::MAX_NAME_LENGTH); + + if (is_float($this->value) && !is_finite($this->value)) { + throw new \InvalidArgumentException('Diagnostic attribute float value must be finite.'); + } + + if (is_string($this->value) && strlen($this->value) > self::MAX_STRING_VALUE_LENGTH) { + throw new \InvalidArgumentException('Diagnostic attribute string value is too long.'); + } + } + + public function name(): string + { + return $this->name; + } + + public function classification(): DiagnosticDataClassification + { + return $this->classification; + } + + public function value(): string|int|float|bool|null + { + return $this->value; + } + + public static function assertBoundedNonEmptyString(string $value, string $field, int $maximumLength): void + { + if ($value === '') { + throw new \InvalidArgumentException($field . ' must not be empty.'); + } + + if (strlen($value) > $maximumLength) { + throw new \InvalidArgumentException($field . ' is too long.'); + } + } +} diff --git a/packages/insight/src/Capture/DiagnosticCaptureFilter.php b/packages/insight/src/Capture/DiagnosticCaptureFilter.php new file mode 100644 index 0000000..50c2ab3 --- /dev/null +++ b/packages/insight/src/Capture/DiagnosticCaptureFilter.php @@ -0,0 +1,41 @@ + + */ + private array $disabledCategories = array(); + + /** + * @var array + */ + private array $disabledNames = array(); + + /** + * @param list $disabledCategories + * @param list $disabledNames + */ + public function __construct(array $disabledCategories = array(), array $disabledNames = array()) + { + foreach ($disabledCategories as $category) { + DiagnosticAttribute::assertBoundedNonEmptyString($category, 'Disabled diagnostic category', DiagnosticEntry::MAX_CATEGORY_LENGTH); + $this->disabledCategories[$category] = true; + } + + foreach ($disabledNames as $name) { + DiagnosticAttribute::assertBoundedNonEmptyString($name, 'Disabled diagnostic name', DiagnosticEntry::MAX_NAME_LENGTH); + $this->disabledNames[$name] = true; + } + } + + public function allows(DiagnosticEntry $entry): bool + { + return !isset($this->disabledCategories[$entry->category()]) + && !isset($this->disabledNames[$entry->name()]); + } +} diff --git a/packages/insight/src/Capture/DiagnosticCapturePolicy.php b/packages/insight/src/Capture/DiagnosticCapturePolicy.php new file mode 100644 index 0000000..073d6bb --- /dev/null +++ b/packages/insight/src/Capture/DiagnosticCapturePolicy.php @@ -0,0 +1,116 @@ + + */ + private array $acceptedClassifications = array(); + + /** + * @param list|null $acceptedClassifications + */ + public function __construct( + ?DiagnosticRedactor $redactor = null, + ?DiagnosticCaptureFilter $filter = null, + ?DeterministicDiagnosticSampler $sampler = null, + ?array $acceptedClassifications = null, + private int $maximumAcceptedAttributeCount = self::DEFAULT_MAXIMUM_ACCEPTED_ATTRIBUTE_COUNT, + ) { + if ($this->maximumAcceptedAttributeCount <= 0 || $this->maximumAcceptedAttributeCount > DiagnosticEntry::MAX_ATTRIBUTE_COUNT) { + throw new \InvalidArgumentException('Maximum accepted diagnostic attribute count must be positive and bounded.'); + } + + $this->redactor = $redactor ?? new DefaultDiagnosticRedactor(); + $this->filter = $filter ?? new DiagnosticCaptureFilter(); + $this->sampler = $sampler ?? new DeterministicDiagnosticSampler(100); + + foreach ($acceptedClassifications ?? $this->defaultAcceptedClassifications() as $classification) { + if ( + $classification === DiagnosticDataClassification::SecretData + || $classification === DiagnosticDataClassification::AuthenticationData + ) { + continue; + } + + $this->acceptedClassifications[$classification->value] = true; + } + } + + public function apply(DiagnosticEntry $candidate): ?DiagnosticEntry + { + $attributes = array(); + $acceptedNames = array(); + + foreach ($candidate->attributes() as $attribute) { + if (!isset($this->acceptedClassifications[$attribute->classification()->value])) { + continue; + } + + try { + $redacted = $this->redactor->redact($attribute); + } catch (\Throwable) { + continue; + } + + if ($redacted === null || !isset($this->acceptedClassifications[$redacted->classification()->value])) { + continue; + } + + if (isset($acceptedNames[$redacted->name()])) { + continue; + } + + $attributes[] = $redacted; + $acceptedNames[$redacted->name()] = true; + + if (count($attributes) >= $this->maximumAcceptedAttributeCount) { + break; + } + } + + if ($attributes === array()) { + return null; + } + + $accepted = new DiagnosticEntry( + $candidate->executionIdentifier(), + $candidate->category(), + $candidate->name(), + $attributes, + ); + + if (!$this->filter->allows($accepted)) { + return null; + } + + if (!$this->sampler->accepts($accepted->executionIdentifier())) { + return null; + } + + return $accepted; + } + + /** + * @return list + */ + private function defaultAcceptedClassifications(): array + { + return array( + DiagnosticDataClassification::PublicOperationalMetadata, + DiagnosticDataClassification::InternalOperationalMetadata, + ); + } +} diff --git a/packages/insight/src/Capture/DiagnosticDataClassification.php b/packages/insight/src/Capture/DiagnosticDataClassification.php new file mode 100644 index 0000000..134b0b5 --- /dev/null +++ b/packages/insight/src/Capture/DiagnosticDataClassification.php @@ -0,0 +1,16 @@ + + */ + private array $attributes; + + /** + * @param array $attributes + */ + public function __construct( + private string $executionIdentifier, + private string $category, + private string $name, + array $attributes, + ) { + DiagnosticAttribute::assertBoundedNonEmptyString( + $this->executionIdentifier, + 'Diagnostic execution identifier', + self::MAX_EXECUTION_IDENTIFIER_LENGTH, + ); + DiagnosticAttribute::assertBoundedNonEmptyString($this->category, 'Diagnostic category', self::MAX_CATEGORY_LENGTH); + DiagnosticAttribute::assertBoundedNonEmptyString($this->name, 'Diagnostic name', self::MAX_NAME_LENGTH); + + if (count($attributes) > self::MAX_ATTRIBUTE_COUNT) { + throw new \InvalidArgumentException('Diagnostic entry attribute count is too large.'); + } + + $validatedAttributes = array(); + + foreach ($attributes as $attribute) { + if (!$attribute instanceof DiagnosticAttribute) { + throw new \InvalidArgumentException('Diagnostic entry attributes must be diagnostic attributes.'); + } + + $validatedAttributes[] = $attribute; + } + + $this->attributes = $validatedAttributes; + } + + public function executionIdentifier(): string + { + return $this->executionIdentifier; + } + + public function category(): string + { + return $this->category; + } + + public function name(): string + { + return $this->name; + } + + /** + * @return list + */ + public function attributes(): array + { + return $this->attributes; + } +} diff --git a/packages/insight/src/Capture/DiagnosticRedactor.php b/packages/insight/src/Capture/DiagnosticRedactor.php new file mode 100644 index 0000000..975e1f5 --- /dev/null +++ b/packages/insight/src/Capture/DiagnosticRedactor.php @@ -0,0 +1,10 @@ +redact($attribute)); + } + + public function testSecretAndAuthenticationClassificationsAreSuppressed(): void + { + $redactor = new DefaultDiagnosticRedactor(); + + self::assertNull($redactor->redact(new DiagnosticAttribute('api_key', DiagnosticDataClassification::SecretData, 'secret'))); + self::assertNull($redactor->redact(new DiagnosticAttribute('authorization', DiagnosticDataClassification::AuthenticationData, 'Bearer token'))); + } + + public function testSensitiveMachineNamesDoNotRetainRawValues(): void + { + foreach ($this->sensitiveMachineNames() as $name) { + $redacted = (new DefaultDiagnosticRedactor())->redact( + new DiagnosticAttribute($name, DiagnosticDataClassification::PublicOperationalMetadata, 'raw-sensitive-value'), + ); + + self::assertInstanceOf(DiagnosticAttribute::class, $redacted); + self::assertSame(DefaultDiagnosticRedactor::REDACTION_MARKER, $redacted->value()); + self::assertSame($name, $redacted->name()); + } + } + + public function testSensitiveNameMatchingIsDeterministic(): void + { + $redactor = new DefaultDiagnosticRedactor(); + $attribute = new DiagnosticAttribute('request.access_token', DiagnosticDataClassification::InternalOperationalMetadata, 'token'); + + self::assertEquals($redactor->redact($attribute), $redactor->redact($attribute)); + } + + public function testOrdinaryNamesAreNotBroadlyOverRedacted(): void + { + $attribute = new DiagnosticAttribute('passwordless_mode', DiagnosticDataClassification::PublicOperationalMetadata, true); + $redacted = (new DefaultDiagnosticRedactor())->redact($attribute); + + self::assertSame($attribute, $redacted); + } + + /** + * @return list + */ + private function sensitiveMachineNames(): array + { + return array( + 'authorization', + 'authentication', + 'password', + 'passwd', + 'cookie', + 'set-cookie', + 'access_token', + 'refresh.token', + 'api-key', + 'secret', + 'session.id', + ); + } +} diff --git a/packages/insight/tests/Unit/Capture/DeterministicDiagnosticSamplerTest.php b/packages/insight/tests/Unit/Capture/DeterministicDiagnosticSamplerTest.php new file mode 100644 index 0000000..48bce1f --- /dev/null +++ b/packages/insight/tests/Unit/Capture/DeterministicDiagnosticSamplerTest.php @@ -0,0 +1,76 @@ +accepts('execution-1')); + self::assertTrue((new DeterministicDiagnosticSampler(100))->accepts('execution-1')); + } + + public function testPercentageOutsideBoundsIsRejected(): void + { + $this->expectException(\InvalidArgumentException::class); + + new DeterministicDiagnosticSampler(101); + } + + public function testSameIdentifierAndConfigurationAlwaysReturnsSameDecision(): void + { + $sampler = new DeterministicDiagnosticSampler(25); + + self::assertSame($sampler->accepts('execution-1'), $sampler->accepts('execution-1')); + } + + public function testDecisionDoesNotDependOnEntryPayload(): void + { + $sampler = new DeterministicDiagnosticSampler(50); + $first = $this->entry('execution-1', 'http', 'request', 'route'); + $second = $this->entry('execution-1', 'database', 'query', 'sql'); + + self::assertSame( + $sampler->accepts($first->executionIdentifier()), + $sampler->accepts($second->executionIdentifier()), + ); + } + + public function testDifferentInstancesWithSameConfigurationMakeSameDecision(): void + { + self::assertSame( + (new DeterministicDiagnosticSampler(50))->accepts('execution-1'), + (new DeterministicDiagnosticSampler(50))->accepts('execution-1'), + ); + } + + public function testImplementationUsesNoAmbientRandomnessOrTime(): void + { + $sampler = new DeterministicDiagnosticSampler(50); + $decisions = array(); + + for ($i = 0; $i < 10; $i++) { + $decisions[] = $sampler->accepts('execution-ambient'); + } + + self::assertCount(1, array_unique($decisions)); + } + + private function entry(string $identifier, string $category, string $name, string $attributeName): DiagnosticEntry + { + return new DiagnosticEntry( + $identifier, + $category, + $name, + array(new DiagnosticAttribute($attributeName, DiagnosticDataClassification::PublicOperationalMetadata, 'value')), + ); + } +} diff --git a/packages/insight/tests/Unit/Capture/DiagnosticCaptureFilterTest.php b/packages/insight/tests/Unit/Capture/DiagnosticCaptureFilterTest.php new file mode 100644 index 0000000..239dc9c --- /dev/null +++ b/packages/insight/tests/Unit/Capture/DiagnosticCaptureFilterTest.php @@ -0,0 +1,56 @@ +allows($this->entry('http', 'request'))); + } + + public function testExactDisabledCategoryRejectsEntry(): void + { + $filter = new DiagnosticCaptureFilter(disabledCategories: array('http')); + + self::assertFalse($filter->allows($this->entry('http', 'request'))); + self::assertTrue($filter->allows($this->entry('database', 'request'))); + } + + public function testExactDisabledNameRejectsEntry(): void + { + $filter = new DiagnosticCaptureFilter(disabledNames: array('request')); + + self::assertFalse($filter->allows($this->entry('http', 'request'))); + self::assertTrue($filter->allows($this->entry('http', 'query'))); + } + + public function testFilterDoesNotMutateCandidate(): void + { + $entry = $this->entry('http', 'request'); + + self::assertFalse((new DiagnosticCaptureFilter(disabledCategories: array('http')))->allows($entry)); + + self::assertSame('http', $entry->category()); + self::assertSame('request', $entry->name()); + self::assertSame('users.show', $entry->attributes()[0]->value()); + } + + private function entry(string $category, string $name): DiagnosticEntry + { + return new DiagnosticEntry( + 'execution-1', + $category, + $name, + array(new DiagnosticAttribute('route', DiagnosticDataClassification::PublicOperationalMetadata, 'users.show')), + ); + } +} diff --git a/packages/insight/tests/Unit/Capture/DiagnosticCapturePolicyTest.php b/packages/insight/tests/Unit/Capture/DiagnosticCapturePolicyTest.php new file mode 100644 index 0000000..42921c1 --- /dev/null +++ b/packages/insight/tests/Unit/Capture/DiagnosticCapturePolicyTest.php @@ -0,0 +1,325 @@ +attributes()); + } + + public function testNestedArraysAndObjectsCannotBeSuppliedThroughAttributeApi(): void + { + try { + new DiagnosticAttribute('payload', DiagnosticDataClassification::PublicOperationalMetadata, $this->nonPrimitiveValue('array')); + self::fail('Expected array attribute value to be rejected.'); + } catch (\TypeError) { + self::addToAssertionCount(1); + } + + try { + new DiagnosticAttribute('payload', DiagnosticDataClassification::PublicOperationalMetadata, $this->nonPrimitiveValue('object')); + self::fail('Expected object attribute value to be rejected.'); + } catch (\TypeError) { + self::addToAssertionCount(1); + } + } + + public function testEmptyAndOversizedNamesOrStringValuesAreRejected(): void + { + foreach ( + array( + static fn (): DiagnosticAttribute => new DiagnosticAttribute('', DiagnosticDataClassification::PublicOperationalMetadata, 'value'), + static fn (): DiagnosticAttribute => new DiagnosticAttribute(str_repeat('a', DiagnosticAttribute::MAX_NAME_LENGTH + 1), DiagnosticDataClassification::PublicOperationalMetadata, 'value'), + static fn (): DiagnosticAttribute => new DiagnosticAttribute('payload', DiagnosticDataClassification::PublicOperationalMetadata, str_repeat('a', DiagnosticAttribute::MAX_STRING_VALUE_LENGTH + 1)), + ) as $factory + ) { + try { + $factory(); + self::fail('Expected invalid diagnostic attribute bounds to be rejected.'); + } catch (\InvalidArgumentException) { + self::addToAssertionCount(1); + } + } + } + + public function testNonFiniteFloatsAreRejected(): void + { + $this->expectException(\InvalidArgumentException::class); + + new DiagnosticAttribute('duration', DiagnosticDataClassification::PublicOperationalMetadata, INF); + } + + public function testOnlyOperationalClassificationsAreAcceptedByDefault(): void + { + $accepted = $this->defaultPolicy()->apply(new DiagnosticEntry( + 'execution-1', + 'http', + 'request', + array( + new DiagnosticAttribute('route', DiagnosticDataClassification::PublicOperationalMetadata, 'users.show'), + new DiagnosticAttribute('worker', DiagnosticDataClassification::InternalOperationalMetadata, 'worker-1'), + new DiagnosticAttribute('email', DiagnosticDataClassification::PersonalData, 'person@example.com'), + new DiagnosticAttribute('invoice', DiagnosticDataClassification::BusinessSensitivePayload, 'invoice-1'), + new DiagnosticAttribute('diagnosis', DiagnosticDataClassification::RegulatedData, 'regulated'), + ), + )); + + self::assertNotNull($accepted); + self::assertSame(array('route', 'worker'), $this->attributeNames($accepted)); + } + + public function testSecretAndAuthenticationValuesAreNeverAcceptedRaw(): void + { + $accepted = $this->defaultPolicy()->apply(new DiagnosticEntry( + 'execution-1', + 'http', + 'request', + array( + new DiagnosticAttribute('api_key', DiagnosticDataClassification::SecretData, 'secret-value'), + new DiagnosticAttribute('authorization', DiagnosticDataClassification::AuthenticationData, 'Bearer token'), + new DiagnosticAttribute('route', DiagnosticDataClassification::PublicOperationalMetadata, 'users.show'), + ), + )); + + self::assertNotNull($accepted); + self::assertSame(array('route'), $this->attributeNames($accepted)); + self::assertSame('users.show', $accepted->attributes()[0]->value()); + } + + public function testCustomRedactorCannotBypassClassificationPolicyByReclassifyingAllowedAttributes(): void + { + $policy = new DiagnosticCapturePolicy(redactor: new ReclassifyingRedactor()); + + $accepted = $policy->apply(new DiagnosticEntry( + 'execution-1', + 'http', + 'request', + array( + new DiagnosticAttribute('secret', DiagnosticDataClassification::PublicOperationalMetadata, 'candidate-secret'), + new DiagnosticAttribute('authentication', DiagnosticDataClassification::PublicOperationalMetadata, 'candidate-authentication'), + new DiagnosticAttribute('personal', DiagnosticDataClassification::PublicOperationalMetadata, 'candidate-personal'), + new DiagnosticAttribute('route', DiagnosticDataClassification::PublicOperationalMetadata, 'users.show'), + ), + )); + + self::assertNotNull($accepted); + self::assertSame(array('route'), $this->attributeNames($accepted)); + self::assertSame('users.show', $accepted->attributes()[0]->value()); + self::assertNotContains('returned-secret', $this->attributeValues($accepted)); + self::assertNotContains('returned-authentication', $this->attributeValues($accepted)); + self::assertNotContains('returned-personal', $this->attributeValues($accepted)); + } + + public function testAcceptedAttributeOrderIsPreservedCountIsBoundedAndDuplicateNamesUseFirstAcceptedWins(): void + { + $policy = new DiagnosticCapturePolicy(maximumAcceptedAttributeCount: 3); + + $accepted = $policy->apply(new DiagnosticEntry( + 'execution-1', + 'http', + 'request', + array( + new DiagnosticAttribute('first', DiagnosticDataClassification::PublicOperationalMetadata, 'one'), + new DiagnosticAttribute('second', DiagnosticDataClassification::PublicOperationalMetadata, 'two'), + new DiagnosticAttribute('second', DiagnosticDataClassification::PublicOperationalMetadata, 'replacement'), + new DiagnosticAttribute('third', DiagnosticDataClassification::InternalOperationalMetadata, 'three'), + new DiagnosticAttribute('fourth', DiagnosticDataClassification::PublicOperationalMetadata, 'four'), + ), + )); + + self::assertNotNull($accepted); + self::assertSame(array('first', 'second', 'third'), $this->attributeNames($accepted)); + self::assertSame('two', $accepted->attributes()[1]->value()); + } + + public function testRedactorNullResultSuppressesAttributeAndThrowingRedactorDoesNotRetainOriginalValue(): void + { + $policy = new DiagnosticCapturePolicy(redactor: new SelectiveFailingRedactor()); + + $accepted = $policy->apply(new DiagnosticEntry( + 'execution-1', + 'http', + 'request', + array( + new DiagnosticAttribute('drop', DiagnosticDataClassification::PublicOperationalMetadata, 'drop-value'), + new DiagnosticAttribute('throw', DiagnosticDataClassification::PublicOperationalMetadata, 'throw-value'), + new DiagnosticAttribute('keep', DiagnosticDataClassification::PublicOperationalMetadata, 'safe'), + ), + )); + + self::assertNotNull($accepted); + self::assertSame(array('keep'), $this->attributeNames($accepted)); + self::assertSame('safe', $accepted->attributes()[0]->value()); + } + + public function testEntryBecomesNullWhenNoAcceptableAttributesRemain(): void + { + self::assertNull($this->defaultPolicy()->apply(new DiagnosticEntry( + 'execution-1', + 'http', + 'request', + array(new DiagnosticAttribute('email', DiagnosticDataClassification::PersonalData, 'person@example.com')), + ))); + } + + public function testFilterAndSamplingRejectionReturnNull(): void + { + $filtered = new DiagnosticCapturePolicy(filter: new DiagnosticCaptureFilter(disabledCategories: array('http'))); + $unsampled = new DiagnosticCapturePolicy(sampler: new DeterministicDiagnosticSampler(0)); + $entry = $this->safeEntry('execution-1', 'http', 'request'); + + self::assertNull($filtered->apply($entry)); + self::assertNull($unsampled->apply($entry)); + } + + public function testPolicyDoesNotMutateOriginalCandidateAndReturnsSafeMinimizedEntry(): void + { + $candidate = new DiagnosticEntry( + 'execution-1', + 'http', + 'request', + array( + new DiagnosticAttribute('authorization', DiagnosticDataClassification::PublicOperationalMetadata, 'Bearer token'), + new DiagnosticAttribute('email', DiagnosticDataClassification::PersonalData, 'person@example.com'), + new DiagnosticAttribute('route', DiagnosticDataClassification::PublicOperationalMetadata, 'users.show'), + ), + ); + + $accepted = $this->defaultPolicy()->apply($candidate); + + self::assertNotNull($accepted); + self::assertSame(array('authorization', 'route'), $this->attributeNames($accepted)); + self::assertSame(DefaultDiagnosticRedactor::REDACTION_MARKER, $accepted->attributes()[0]->value()); + self::assertSame('Bearer token', $candidate->attributes()[0]->value()); + self::assertSame('person@example.com', $candidate->attributes()[1]->value()); + } + + public function testSamplingDecisionIsStableAcrossDifferentlyNamedCandidateEntries(): void + { + $policy = new DiagnosticCapturePolicy(sampler: new DeterministicDiagnosticSampler(50)); + $first = $policy->apply($this->safeEntry('execution-stable', 'http', 'request')); + $second = $policy->apply($this->safeEntry('execution-stable', 'db', 'query')); + + self::assertSame($first instanceof DiagnosticEntry, $second instanceof DiagnosticEntry); + } + + private function defaultPolicy(): DiagnosticCapturePolicy + { + return new DiagnosticCapturePolicy(); + } + + private function nonPrimitiveValue(string $kind): mixed + { + if ($kind === 'array') { + return array('nested' => 'value'); + } + + return new \stdClass(); + } + + private function safeEntry(string $identifier, string $category, string $name): DiagnosticEntry + { + return new DiagnosticEntry( + $identifier, + $category, + $name, + array(new DiagnosticAttribute('route', DiagnosticDataClassification::PublicOperationalMetadata, 'users.show')), + ); + } + + /** + * @return list + */ + private function attributeNames(DiagnosticEntry $entry): array + { + return array_map( + static fn (DiagnosticAttribute $attribute): string => $attribute->name(), + $entry->attributes(), + ); + } + + /** + * @return list + */ + private function attributeValues(DiagnosticEntry $entry): array + { + return array_map( + static fn (DiagnosticAttribute $attribute): string|int|float|bool|null => $attribute->value(), + $entry->attributes(), + ); + } +} + +final class SelectiveFailingRedactor implements DiagnosticRedactor +{ + public function redact(DiagnosticAttribute $attribute): ?DiagnosticAttribute + { + if ($attribute->name() === 'drop') { + return null; + } + + if ($attribute->name() === 'throw') { + throw new \RuntimeException('redaction failed'); + } + + return $attribute; + } +} + +final class ReclassifyingRedactor implements DiagnosticRedactor +{ + public function redact(DiagnosticAttribute $attribute): DiagnosticAttribute + { + return match ($attribute->name()) { + 'secret' => new DiagnosticAttribute('secret', DiagnosticDataClassification::SecretData, 'returned-secret'), + 'authentication' => new DiagnosticAttribute('authentication', DiagnosticDataClassification::AuthenticationData, 'returned-authentication'), + 'personal' => new DiagnosticAttribute('personal', DiagnosticDataClassification::PersonalData, 'returned-personal'), + default => $attribute, + }; + } +} diff --git a/tests/Architecture/EvolvePhp2PackageSkeletonTest.php b/tests/Architecture/EvolvePhp2PackageSkeletonTest.php index 8bba960..c4bb6f6 100644 --- a/tests/Architecture/EvolvePhp2PackageSkeletonTest.php +++ b/tests/Architecture/EvolvePhp2PackageSkeletonTest.php @@ -1013,6 +1013,14 @@ private function acceptedPackageSourceInventories() 'Lifecycle/ApplicationState.php', ), 'packages/insight/src' => array( + 'Capture/DefaultDiagnosticRedactor.php', + 'Capture/DeterministicDiagnosticSampler.php', + 'Capture/DiagnosticAttribute.php', + 'Capture/DiagnosticCaptureFilter.php', + 'Capture/DiagnosticCapturePolicy.php', + 'Capture/DiagnosticDataClassification.php', + 'Capture/DiagnosticEntry.php', + 'Capture/DiagnosticRedactor.php', 'DiagnosticBatch.php', 'DiagnosticBatchCollector.php', 'DiagnosticBatchSink.php',