diff --git a/CHANGELOG.md b/CHANGELOG.md index 33fc32b..3b8e965 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### 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 persistence, retention, redaction, 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 persistent storage and retention deferred. ### Repository diff --git a/packages/insight/README.md b/packages/insight/README.md index 457c593..62d717f 100644 --- a/packages/insight/README.md +++ b/packages/insight/README.md @@ -2,12 +2,14 @@ `evolvephp/insight` -Diagnostic batch collection foundation for EvolvePHP 2. +Diagnostic batch collection and storage-projection foundation for EvolvePHP 2. ## Responsibility Evolve Insight consumes safe Core execution observations and collects them into immutable, bounded diagnostic batches. This package provides the storage-neutral `DiagnosticBatchSink` contract, immutable `DiagnosticBatch` values and `DiagnosticBatchCollector`, which implements Core's `ObservationSink` boundary. +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. + Current bounded behavior: - collection starts only after Core reports an execution start @@ -18,6 +20,16 @@ Current bounded behavior: - completed executions are forgotten before the finalized batch is handed to the configured sink - sink failures propagate to the existing Core instrumentation boundary +Current storage behavior: + +- `DiagnosticBatchStore` defines minimal save, exact execution-identifier lookup and newest-first bounded reads +- `InMemoryDiagnosticBatchStore` keeps snapshots in insertion order and returns newest batches first +- duplicate execution identifiers are rejected and never replace the original snapshot +- `StoringDiagnosticBatchSink` projects accepted batches and saves the detached snapshot through a configured store +- storage remains optional and unwired; installing Insight does not create storage automatically + +The in-memory store is unbounded and suitable only for tests or short-lived local development. It is not a persistent runtime store. + ## Requirements PHP `^8.4` @@ -34,7 +46,7 @@ https://github.com/josiahking/evolvephp ## Current Limitations -This package does not provide persistence, retention, pruning, redaction, filtering, sampling, dashboards, watchers, OpenTelemetry, Evolve Observe, trace propagation, runtime composition, automatic registration, storage adapters or production-ready diagnostics. +This package does not provide filesystem or database persistence, retention, pruning, redaction, rich diagnostic capture, filtering, sampling, dashboards, watchers, OpenTelemetry, Evolve Observe, trace propagation, runtime composition, automatic registration, persistent storage adapters or production-ready diagnostics. ## Licence diff --git a/packages/insight/src/Storage/DiagnosticBatchProjector.php b/packages/insight/src/Storage/DiagnosticBatchProjector.php new file mode 100644 index 0000000..3503464 --- /dev/null +++ b/packages/insight/src/Storage/DiagnosticBatchProjector.php @@ -0,0 +1,67 @@ +identifier()->value(), + $batch->kind()->value, + array_map( + fn (Observation $observation): DiagnosticObservationSnapshot => $this->projectObservation($observation), + $batch->observations(), + ), + $batch->droppedObservationCount(), + ); + } + + private function projectObservation(Observation $observation): DiagnosticObservationSnapshot + { + return new DiagnosticObservationSnapshot( + $this->observationType($observation->type()), + $this->outcome($observation->outcome()), + $observation->errorType(), + $this->reuseDecision($observation->reuseDecision()), + ); + } + + private function observationType(ObservationType $type): string + { + return match ($type) { + ObservationType::ExecutionStarted => 'execution-started', + ObservationType::HandlerCompleted => 'handler-completed', + ObservationType::ScopeCloseStarted => 'scope-close-started', + ObservationType::ScopeCloseCompleted => 'scope-close-completed', + ObservationType::QuarantineRequired => 'quarantine-required', + ObservationType::ExecutionCompleted => 'execution-completed', + }; + } + + private function outcome(?ObservationOutcome $outcome): ?string + { + return match ($outcome) { + ObservationOutcome::Succeeded => 'succeeded', + ObservationOutcome::Failed => 'failed', + null => null, + }; + } + + private function reuseDecision(?ProcessReuseDecision $decision): ?string + { + return match ($decision) { + ProcessReuseDecision::Reusable => 'reusable', + ProcessReuseDecision::QuarantineRequired => 'quarantine-required', + null => null, + }; + } +} diff --git a/packages/insight/src/Storage/DiagnosticBatchSnapshot.php b/packages/insight/src/Storage/DiagnosticBatchSnapshot.php new file mode 100644 index 0000000..855521c --- /dev/null +++ b/packages/insight/src/Storage/DiagnosticBatchSnapshot.php @@ -0,0 +1,56 @@ + + */ + private array $observations; + + /** + * @param list $observations + */ + public function __construct( + private string $executionIdentifier, + private string $executionKind, + array $observations, + private int $droppedObservationCount, + ) { + if ($this->executionIdentifier === '') { + throw new \InvalidArgumentException('Execution identifier must not be empty.'); + } + + if ($this->droppedObservationCount < 0) { + throw new \InvalidArgumentException('Dropped observation count must not be negative.'); + } + + $this->observations = $observations; + } + + public function executionIdentifier(): string + { + return $this->executionIdentifier; + } + + public function executionKind(): string + { + return $this->executionKind; + } + + /** + * @return list + */ + public function observations(): array + { + return $this->observations; + } + + public function droppedObservationCount(): int + { + return $this->droppedObservationCount; + } +} diff --git a/packages/insight/src/Storage/DiagnosticBatchStore.php b/packages/insight/src/Storage/DiagnosticBatchStore.php new file mode 100644 index 0000000..412c709 --- /dev/null +++ b/packages/insight/src/Storage/DiagnosticBatchStore.php @@ -0,0 +1,17 @@ + + */ + public function latest(int $limit): array; +} diff --git a/packages/insight/src/Storage/DiagnosticObservationSnapshot.php b/packages/insight/src/Storage/DiagnosticObservationSnapshot.php new file mode 100644 index 0000000..5f8ed07 --- /dev/null +++ b/packages/insight/src/Storage/DiagnosticObservationSnapshot.php @@ -0,0 +1,35 @@ +type; + } + + public function outcome(): ?string + { + return $this->outcome; + } + + public function errorType(): ?string + { + return $this->errorType; + } + + public function reuseDecision(): ?string + { + return $this->reuseDecision; + } +} diff --git a/packages/insight/src/Storage/InMemoryDiagnosticBatchStore.php b/packages/insight/src/Storage/InMemoryDiagnosticBatchStore.php new file mode 100644 index 0000000..7618b1b --- /dev/null +++ b/packages/insight/src/Storage/InMemoryDiagnosticBatchStore.php @@ -0,0 +1,49 @@ + + */ + private array $snapshotsByIdentifier = array(); + + /** + * @var list + */ + private array $insertionOrder = array(); + + public function save(DiagnosticBatchSnapshot $snapshot): void + { + $identifier = $snapshot->executionIdentifier(); + + if (isset($this->snapshotsByIdentifier[$identifier])) { + throw new \LogicException('Diagnostic batch snapshot already exists for execution identifier.'); + } + + $this->snapshotsByIdentifier[$identifier] = $snapshot; + $this->insertionOrder[] = $identifier; + } + + public function find(string $executionIdentifier): ?DiagnosticBatchSnapshot + { + return $this->snapshotsByIdentifier[$executionIdentifier] ?? null; + } + + public function latest(int $limit): array + { + if ($limit <= 0) { + throw new \InvalidArgumentException('Latest limit must be positive.'); + } + + $identifiers = array_slice(array_reverse($this->insertionOrder), 0, $limit); + + return array_map( + fn (string $identifier): DiagnosticBatchSnapshot => $this->snapshotsByIdentifier[$identifier], + $identifiers, + ); + } +} diff --git a/packages/insight/src/Storage/StoringDiagnosticBatchSink.php b/packages/insight/src/Storage/StoringDiagnosticBatchSink.php new file mode 100644 index 0000000..d9bc694 --- /dev/null +++ b/packages/insight/src/Storage/StoringDiagnosticBatchSink.php @@ -0,0 +1,21 @@ +store->save($this->projector->project($batch)); + } +} diff --git a/packages/insight/tests/Unit/Storage/DiagnosticBatchProjectorTest.php b/packages/insight/tests/Unit/Storage/DiagnosticBatchProjectorTest.php new file mode 100644 index 0000000..8d4e24a --- /dev/null +++ b/packages/insight/tests/Unit/Storage/DiagnosticBatchProjectorTest.php @@ -0,0 +1,158 @@ +project($batch); + + self::assertSame($identifier->value(), $snapshot->executionIdentifier()); + self::assertSame('cli-command', $snapshot->executionKind()); + self::assertSame(3, $snapshot->droppedObservationCount()); + self::assertEquals( + array( + new DiagnosticObservationSnapshot('execution-started', null, null, null), + new DiagnosticObservationSnapshot('execution-completed', 'succeeded', null, 'reusable'), + ), + $snapshot->observations(), + ); + } + + public function testEveryCurrentObservationTypeCanBeProjected(): void + { + $identifier = ExecutionIdentifier::generate(); + $batch = new DiagnosticBatch( + $identifier, + ExecutionKind::WorkerTask, + array_map( + static fn (ObservationType $type): Observation => new Observation($type, $identifier, ExecutionKind::WorkerTask), + ObservationType::cases(), + ), + 0, + ); + + $snapshot = (new DiagnosticBatchProjector())->project($batch); + + self::assertSame( + array( + 'execution-started', + 'handler-completed', + 'scope-close-started', + 'scope-close-completed', + 'quarantine-required', + 'execution-completed', + ), + array_map( + static fn (DiagnosticObservationSnapshot $observation): string => $observation->type(), + $snapshot->observations(), + ), + ); + } + + public function testOutcomeErrorTypeAndReuseDecisionConversionsRemainNullableWhenAbsent(): void + { + $identifier = ExecutionIdentifier::generate(); + $batch = new DiagnosticBatch( + $identifier, + ExecutionKind::HttpRequest, + array(new Observation(ObservationType::HandlerCompleted, $identifier, ExecutionKind::HttpRequest)), + 0, + ); + + $observation = (new DiagnosticBatchProjector())->project($batch)->observations()[0]; + + self::assertNull($observation->outcome()); + self::assertNull($observation->errorType()); + self::assertNull($observation->reuseDecision()); + } + + public function testOutcomeErrorTypeAndReuseDecisionConversionsUseStableStrings(): void + { + $identifier = ExecutionIdentifier::generate(); + $batch = new DiagnosticBatch( + $identifier, + ExecutionKind::HttpRequest, + array( + new Observation( + ObservationType::HandlerCompleted, + $identifier, + ExecutionKind::HttpRequest, + ObservationOutcome::Failed, + 'LogicException', + ProcessReuseDecision::QuarantineRequired, + ), + ), + 0, + ); + + $observation = (new DiagnosticBatchProjector())->project($batch)->observations()[0]; + + self::assertSame('failed', $observation->outcome()); + self::assertSame('LogicException', $observation->errorType()); + self::assertSame('quarantine-required', $observation->reuseDecision()); + } + + public function testSnapshotApiDoesNotExposeSourceCoreObjects(): void + { + $identifier = ExecutionIdentifier::generate(); + $batch = new DiagnosticBatch( + $identifier, + ExecutionKind::QueueMessage, + array(new Observation(ObservationType::ExecutionStarted, $identifier, ExecutionKind::QueueMessage)), + 0, + ); + + $snapshot = (new DiagnosticBatchProjector())->project($batch); + + self::assertContainsOnlyInstancesOf(DiagnosticObservationSnapshot::class, $snapshot->observations()); + self::assertSame( + array('droppedObservationCount', 'executionIdentifier', 'executionKind', 'observations'), + $this->publicMethods($snapshot), + ); + } + + /** + * @return list + */ + private function publicMethods(object $object): array + { + $methods = array_map( + static fn (\ReflectionMethod $method): string => $method->getName(), + (new \ReflectionClass($object))->getMethods(\ReflectionMethod::IS_PUBLIC), + ); + $methods = array_values(array_filter( + $methods, + static fn (string $method): bool => $method !== '__construct', + )); + sort($methods); + + return $methods; + } +} diff --git a/packages/insight/tests/Unit/Storage/DiagnosticBatchSnapshotTest.php b/packages/insight/tests/Unit/Storage/DiagnosticBatchSnapshotTest.php new file mode 100644 index 0000000..4d57419 --- /dev/null +++ b/packages/insight/tests/Unit/Storage/DiagnosticBatchSnapshotTest.php @@ -0,0 +1,62 @@ +executionIdentifier()); + self::assertSame('http-request', $snapshot->executionKind()); + self::assertSame(array($first, $second), $snapshot->observations()); + self::assertSame(2, $snapshot->droppedObservationCount()); + } + + public function testObservationCollectionsDoNotExposeMutableInternalState(): void + { + $first = new DiagnosticObservationSnapshot('execution-started', null, null, null); + $snapshot = new DiagnosticBatchSnapshot( + 'execution-123', + 'http-request', + array($first), + 0, + ); + + $observations = $snapshot->observations(); + $observations[] = new DiagnosticObservationSnapshot('execution-completed', null, null, null); + + self::assertSame(array($first), $snapshot->observations()); + } + + public function testNegativeDroppedCountIsRejected(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Dropped observation count must not be negative.'); + + new DiagnosticBatchSnapshot('execution-123', 'http-request', array(), -1); + } + + public function testEmptyIdentifierIsRejected(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Execution identifier must not be empty.'); + + new DiagnosticBatchSnapshot('', 'http-request', array(), 0); + } +} diff --git a/packages/insight/tests/Unit/Storage/DiagnosticObservationSnapshotTest.php b/packages/insight/tests/Unit/Storage/DiagnosticObservationSnapshotTest.php new file mode 100644 index 0000000..b8c71b8 --- /dev/null +++ b/packages/insight/tests/Unit/Storage/DiagnosticObservationSnapshotTest.php @@ -0,0 +1,74 @@ +type()); + self::assertSame('succeeded', $snapshot->outcome()); + self::assertSame('RuntimeException', $snapshot->errorType()); + self::assertSame('reusable', $snapshot->reuseDecision()); + } + + public function testNullableFieldsRemainNullable(): void + { + $snapshot = new DiagnosticObservationSnapshot( + 'execution-started', + null, + null, + null, + ); + + self::assertSame('execution-started', $snapshot->type()); + self::assertNull($snapshot->outcome()); + self::assertNull($snapshot->errorType()); + self::assertNull($snapshot->reuseDecision()); + } + + public function testPublicApiExposesNoCoreObservation(): void + { + $snapshot = new DiagnosticObservationSnapshot( + 'handler-completed', + 'failed', + 'DomainException', + 'quarantine-required', + ); + + self::assertSame( + array('errorType', 'outcome', 'reuseDecision', 'type'), + $this->publicMethods($snapshot), + ); + } + + /** + * @return list + */ + private function publicMethods(object $object): array + { + $methods = array_map( + static fn (\ReflectionMethod $method): string => $method->getName(), + (new \ReflectionClass($object))->getMethods(\ReflectionMethod::IS_PUBLIC), + ); + $methods = array_values(array_filter( + $methods, + static fn (string $method): bool => $method !== '__construct', + )); + sort($methods); + + return $methods; + } +} diff --git a/packages/insight/tests/Unit/Storage/InMemoryDiagnosticBatchStoreTest.php b/packages/insight/tests/Unit/Storage/InMemoryDiagnosticBatchStoreTest.php new file mode 100644 index 0000000..4baf101 --- /dev/null +++ b/packages/insight/tests/Unit/Storage/InMemoryDiagnosticBatchStoreTest.php @@ -0,0 +1,101 @@ +snapshot('execution-1'); + + $store->save($snapshot); + + self::assertSame($snapshot, $store->find('execution-1')); + } + + public function testUnknownFindReturnsNullAndExactIdentifierSemanticsAreUsed(): void + { + $store = new InMemoryDiagnosticBatchStore(); + $store->save($this->snapshot('execution-10')); + + self::assertNull($store->find('execution-1')); + self::assertNull($store->find('')); + } + + public function testMultipleSavesAndLatestAreDeterministicNewestFirst(): void + { + $store = new InMemoryDiagnosticBatchStore(); + $first = $this->snapshot('execution-1'); + $second = $this->snapshot('execution-2'); + $third = $this->snapshot('execution-3'); + + $store->save($first); + $store->save($second); + $store->save($third); + + self::assertSame(array($third, $second, $first), $store->latest(10)); + self::assertSame(array($third, $second), $store->latest(2)); + self::assertSame(array($third, $second, $first), $store->latest(10)); + } + + public function testLatestLimitGreaterThanCountReturnsAllSnapshots(): void + { + $store = new InMemoryDiagnosticBatchStore(); + $first = $this->snapshot('execution-1'); + + $store->save($first); + + self::assertSame(array($first), $store->latest(5)); + } + + public function testZeroLimitIsRejected(): void + { + $store = new InMemoryDiagnosticBatchStore(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Latest limit must be positive.'); + + $store->latest(0); + } + + public function testNegativeLimitIsRejected(): void + { + $store = new InMemoryDiagnosticBatchStore(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Latest limit must be positive.'); + + $store->latest(-1); + } + + public function testDuplicateIdentifierIsRejectedAndDoesNotReplaceOriginal(): void + { + $store = new InMemoryDiagnosticBatchStore(); + $original = $this->snapshot('execution-1', 'http-request'); + $duplicate = $this->snapshot('execution-1', 'cli-command'); + + $store->save($original); + + try { + $store->save($duplicate); + self::fail('Expected duplicate execution identifier to be rejected.'); + } catch (\LogicException $exception) { + self::assertSame('Diagnostic batch snapshot already exists for execution identifier.', $exception->getMessage()); + } + + self::assertSame($original, $store->find('execution-1')); + self::assertSame(array($original), $store->latest(10)); + } + + private function snapshot(string $identifier, string $kind = 'http-request'): DiagnosticBatchSnapshot + { + return new DiagnosticBatchSnapshot($identifier, $kind, array(), 0); + } +} diff --git a/packages/insight/tests/Unit/Storage/StoringDiagnosticBatchSinkTest.php b/packages/insight/tests/Unit/Storage/StoringDiagnosticBatchSinkTest.php new file mode 100644 index 0000000..52eeef6 --- /dev/null +++ b/packages/insight/tests/Unit/Storage/StoringDiagnosticBatchSinkTest.php @@ -0,0 +1,92 @@ +implementsInterface(DiagnosticBatchSink::class)); + } + + public function testItProjectsThenStoresOneFinalizedBatch(): void + { + $store = new InMemoryDiagnosticBatchStore(); + $sink = new StoringDiagnosticBatchSink(new DiagnosticBatchProjector(), $store); + $identifier = ExecutionIdentifier::generate(); + $batch = new DiagnosticBatch( + $identifier, + ExecutionKind::ScheduledJob, + array(new Observation(ObservationType::ExecutionStarted, $identifier, ExecutionKind::ScheduledJob)), + 1, + ); + + $sink->accept($batch); + + self::assertEquals((new DiagnosticBatchProjector())->project($batch), $store->find($identifier->value())); + } + + public function testStoreFailurePropagates(): void + { + $sink = new StoringDiagnosticBatchSink( + new DiagnosticBatchProjector(), + new ThrowingDiagnosticBatchStore(new \RuntimeException('store failed')), + ); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('store failed'); + + $sink->accept(new DiagnosticBatch(ExecutionIdentifier::generate(), ExecutionKind::HttpRequest, array(), 0)); + } + + public function testProjectorStoreCollaborationDoesNotMutateOriginalBatch(): void + { + $store = new InMemoryDiagnosticBatchStore(); + $sink = new StoringDiagnosticBatchSink(new DiagnosticBatchProjector(), $store); + $identifier = ExecutionIdentifier::generate(); + $observation = new Observation(ObservationType::ExecutionCompleted, $identifier, ExecutionKind::QueueMessage); + $batch = new DiagnosticBatch($identifier, ExecutionKind::QueueMessage, array($observation), 4); + + $sink->accept($batch); + + self::assertSame($identifier, $batch->identifier()); + self::assertSame(ExecutionKind::QueueMessage, $batch->kind()); + self::assertSame(array($observation), $batch->observations()); + self::assertSame(4, $batch->droppedObservationCount()); + } +} + +final class ThrowingDiagnosticBatchStore implements DiagnosticBatchStore +{ + public function __construct(private \Throwable $throwable) {} + + public function save(DiagnosticBatchSnapshot $snapshot): void + { + throw $this->throwable; + } + + public function find(string $executionIdentifier): ?DiagnosticBatchSnapshot + { + return null; + } + + public function latest(int $limit): array + { + return array(); + } +} diff --git a/tests/Architecture/EvolvePhp2PackageSkeletonTest.php b/tests/Architecture/EvolvePhp2PackageSkeletonTest.php index dfa66e4..75044c1 100644 --- a/tests/Architecture/EvolvePhp2PackageSkeletonTest.php +++ b/tests/Architecture/EvolvePhp2PackageSkeletonTest.php @@ -1016,6 +1016,12 @@ private function acceptedPackageSourceInventories() 'DiagnosticBatch.php', 'DiagnosticBatchCollector.php', 'DiagnosticBatchSink.php', + 'Storage/DiagnosticBatchProjector.php', + 'Storage/DiagnosticBatchSnapshot.php', + 'Storage/DiagnosticBatchStore.php', + 'Storage/DiagnosticObservationSnapshot.php', + 'Storage/InMemoryDiagnosticBatchStore.php', + 'Storage/StoringDiagnosticBatchSink.php', ), 'packages/dev-tools/src' => array( 'Adoption/AdoptionPlan.php', diff --git a/tests/Documentation/EvolvePhp2ReleaseReadinessTest.php b/tests/Documentation/EvolvePhp2ReleaseReadinessTest.php index cb430c2..22342d2 100644 --- a/tests/Documentation/EvolvePhp2ReleaseReadinessTest.php +++ b/tests/Documentation/EvolvePhp2ReleaseReadinessTest.php @@ -208,7 +208,7 @@ private function packages() 'name' => 'evolvephp/insight', 'directory' => 'packages/insight', 'human' => 'EvolvePHP Insight', - 'responsibility' => 'Diagnostic batch collection foundation for EvolvePHP 2.', + 'responsibility' => 'Diagnostic batch collection and storage-projection foundation for EvolvePHP 2.', 'dependencies' => '`evolvephp/core`', ), array(