Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@

### 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.
- 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 runtime wiring, retention, pruning and application database integration 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 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 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.

### Repository

Expand Down
10 changes: 6 additions & 4 deletions packages/insight/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,16 @@ Current bounded behavior:
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
- `SqliteDiagnosticBatchStore` provides an optional persistent local-development adapter for caller-supplied SQLite `PDO` connections
- `InMemoryDiagnosticBatchStore` keeps snapshots in insertion order, requires an explicit positive stored-batch count and prunes oldest retained snapshots first
- `SqliteDiagnosticBatchStore` provides an optional persistent local-development adapter for caller-supplied SQLite `PDO` connections, requires an explicit positive stored-batch count and prunes by SQLite sequence order
- `DiagnosticBatchSnapshotCodec` stores snapshots as a versioned primitive JSON payload and rejects malformed, unsupported or unexpected persisted data during reads
- 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. The SQLite store creates its diagnostic table only when explicitly constructed with a SQLite `PDO`; it does not discover a default path, read application database configuration or automatically use application storage. SQLite reads use deterministic insertion-order sequence values for newest-first results, not diagnostic timestamps. Corrupt stored payloads are not decoded during construction, but the affected `find()` or `latest()` read fails explicitly. Neither store provides retention, pruning or eviction.
Both first-party stores use explicit count-bounded retention. Callers configure a positive maximum stored-batch count, and successful unique saves leave no more than that many retained diagnostic batch snapshots. Pruning is deterministic oldest-first insertion order; SQLite uses its monotonic sequence column as the insertion-order authority. Duplicate execution identifiers are rejected before pruning, so a duplicate save at capacity does not evict or mutate retained snapshots. Time-based retention is not provided.

The SQLite store creates its diagnostic table only when explicitly constructed with a SQLite `PDO`; it does not discover a default path, read application database configuration or automatically use application storage. SQLite reads use deterministic insertion-order sequence values for newest-first results, not diagnostic timestamps. Corrupt stored payloads are not decoded during construction, but the affected `find()` or `latest()` read fails explicitly.

`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.

Expand All @@ -50,7 +52,7 @@ https://github.com/josiahking/evolvephp

## Current Limitations

This package does not provide retention, pruning, 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, redaction, rich diagnostic capture, filtering, sampling, dashboards, watchers, OpenTelemetry, Evolve Observe, trace propagation, runtime composition, automatic registration, application database integration or production-ready diagnostics.

## Licence

Expand Down
22 changes: 22 additions & 0 deletions packages/insight/src/Storage/InMemoryDiagnosticBatchStore.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ final class InMemoryDiagnosticBatchStore implements DiagnosticBatchStore
*/
private array $insertionOrder = array();

public function __construct(private int $maximumStoredBatchCount)
{
if ($this->maximumStoredBatchCount <= 0) {
throw new \InvalidArgumentException('Maximum stored diagnostic batch count must be positive.');
}
}

public function save(DiagnosticBatchSnapshot $snapshot): void
{
$identifier = $snapshot->executionIdentifier();
Expand All @@ -24,6 +31,8 @@ public function save(DiagnosticBatchSnapshot $snapshot): void
throw new \LogicException('Diagnostic batch snapshot already exists for execution identifier.');
}

$this->pruneOldestSnapshotsForIncomingSave();

$this->snapshotsByIdentifier[$identifier] = $snapshot;
$this->insertionOrder[] = $identifier;
}
Expand All @@ -46,4 +55,17 @@ public function latest(int $limit): array
$identifiers,
);
}

private function pruneOldestSnapshotsForIncomingSave(): void
{
while (count($this->insertionOrder) >= $this->maximumStoredBatchCount) {
$oldestIdentifier = array_shift($this->insertionOrder);

if ($oldestIdentifier === null) {
return;
}

unset($this->snapshotsByIdentifier[$oldestIdentifier]);
}
}
}
49 changes: 46 additions & 3 deletions packages/insight/src/Storage/SqliteDiagnosticBatchStore.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,19 @@ final class SqliteDiagnosticBatchStore implements DiagnosticBatchStore

private DiagnosticBatchSnapshotCodec $codec;

public function __construct(private \PDO $pdo, ?DiagnosticBatchSnapshotCodec $codec = null)
{
public function __construct(
private \PDO $pdo,
private int $maximumStoredBatchCount,
?DiagnosticBatchSnapshotCodec $codec = null,
) {
if ($this->pdo->getAttribute(\PDO::ATTR_DRIVER_NAME) !== 'sqlite') {
throw new \InvalidArgumentException('Sqlite diagnostic batch store requires a SQLite PDO connection.');
}

if ($this->maximumStoredBatchCount <= 0) {
throw new \InvalidArgumentException('Maximum stored diagnostic batch count must be positive.');
}

$this->codec = $codec ?? new DiagnosticBatchSnapshotCodec();
$this->createSchema();
}
Expand All @@ -28,12 +35,16 @@ public function save(DiagnosticBatchSnapshot $snapshot): void
throw new \LogicException('Diagnostic batch snapshot already exists for execution identifier.');
}

$payload = $this->codec->encode($snapshot);

$this->pruneOldestSnapshotsForIncomingSave();

$statement = $this->prepare(
'INSERT INTO ' . self::TABLE . ' (execution_identifier, snapshot_payload) VALUES (:execution_identifier, :snapshot_payload)'
);
$this->execute($statement, array(
'execution_identifier' => $identifier,
'snapshot_payload' => $this->codec->encode($snapshot),
'snapshot_payload' => $payload,
));
}

Expand Down Expand Up @@ -101,6 +112,38 @@ private function identifierExists(string $identifier): bool
return $statement->fetchColumn() !== false;
}

private function pruneOldestSnapshotsForIncomingSave(): void
{
$storedBatchCount = $this->storedBatchCount();

if ($storedBatchCount < $this->maximumStoredBatchCount) {
return;
}

$deleteCount = $storedBatchCount - $this->maximumStoredBatchCount + 1;
$statement = $this->prepare(
'DELETE FROM ' . self::TABLE . '
WHERE sequence IN (
SELECT sequence FROM ' . self::TABLE . '
ORDER BY sequence ASC
LIMIT :delete_count
)'
);
$statement->bindValue('delete_count', $deleteCount, \PDO::PARAM_INT);

if (!$statement->execute()) {
throw new \RuntimeException('Failed to execute SQLite diagnostic batch store statement.');
}
}

private function storedBatchCount(): int
{
$statement = $this->prepare('SELECT COUNT(*) FROM ' . self::TABLE);
$this->execute($statement, array());

return (int) $statement->fetchColumn();
}

/**
* @param array<string, mixed> $row
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,33 @@ final class InMemoryDiagnosticBatchStoreTest extends TestCase
{
public function testSaveThenFindReturnsTheStoredSnapshot(): void
{
$store = new InMemoryDiagnosticBatchStore();
$store = new InMemoryDiagnosticBatchStore(10);
$snapshot = $this->snapshot('execution-1');

$store->save($snapshot);

self::assertSame($snapshot, $store->find('execution-1'));
}

public function testZeroMaximumStoredBatchCountIsRejected(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Maximum stored diagnostic batch count must be positive.');

new InMemoryDiagnosticBatchStore(0);
}

public function testNegativeMaximumStoredBatchCountIsRejected(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Maximum stored diagnostic batch count must be positive.');

new InMemoryDiagnosticBatchStore(-1);
}

public function testUnknownFindReturnsNullAndExactIdentifierSemanticsAreUsed(): void
{
$store = new InMemoryDiagnosticBatchStore();
$store = new InMemoryDiagnosticBatchStore(10);
$store->save($this->snapshot('execution-10'));

self::assertNull($store->find('execution-1'));
Expand All @@ -31,7 +47,7 @@ public function testUnknownFindReturnsNullAndExactIdentifierSemanticsAreUsed():

public function testMultipleSavesAndLatestAreDeterministicNewestFirst(): void
{
$store = new InMemoryDiagnosticBatchStore();
$store = new InMemoryDiagnosticBatchStore(10);
$first = $this->snapshot('execution-1');
$second = $this->snapshot('execution-2');
$third = $this->snapshot('execution-3');
Expand All @@ -47,17 +63,62 @@ public function testMultipleSavesAndLatestAreDeterministicNewestFirst(): void

public function testLatestLimitGreaterThanCountReturnsAllSnapshots(): void
{
$store = new InMemoryDiagnosticBatchStore();
$store = new InMemoryDiagnosticBatchStore(10);
$first = $this->snapshot('execution-1');

$store->save($first);

self::assertSame(array($first), $store->latest(5));
}

public function testBelowCapacitySavesDoNotEvict(): void
{
$store = new InMemoryDiagnosticBatchStore(3);
$first = $this->snapshot('execution-1');
$second = $this->snapshot('execution-2');

$store->save($first);
$store->save($second);

self::assertSame($first, $store->find('execution-1'));
self::assertSame($second, $store->find('execution-2'));
self::assertSame(array($second, $first), $store->latest(10));
}

public function testCapacityEvictsOldestSnapshotBeforeSavingUniqueSnapshot(): void
{
$store = new InMemoryDiagnosticBatchStore(2);
$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::assertNull($store->find('execution-1'));
self::assertSame($second, $store->find('execution-2'));
self::assertSame($third, $store->find('execution-3'));
self::assertSame(array($third, $second), $store->latest(10));
}

public function testMaximumOneRetainsOnlyNewestSuccessfullyStoredSnapshot(): void
{
$store = new InMemoryDiagnosticBatchStore(1);
$first = $this->snapshot('execution-1');
$second = $this->snapshot('execution-2');

$store->save($first);
$store->save($second);

self::assertNull($store->find('execution-1'));
self::assertSame($second, $store->find('execution-2'));
self::assertSame(array($second), $store->latest(10));
}

public function testZeroLimitIsRejected(): void
{
$store = new InMemoryDiagnosticBatchStore();
$store = new InMemoryDiagnosticBatchStore(10);

$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Latest limit must be positive.');
Expand All @@ -67,7 +128,7 @@ public function testZeroLimitIsRejected(): void

public function testNegativeLimitIsRejected(): void
{
$store = new InMemoryDiagnosticBatchStore();
$store = new InMemoryDiagnosticBatchStore(10);

$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Latest limit must be positive.');
Expand All @@ -77,7 +138,7 @@ public function testNegativeLimitIsRejected(): void

public function testDuplicateIdentifierIsRejectedAndDoesNotReplaceOriginal(): void
{
$store = new InMemoryDiagnosticBatchStore();
$store = new InMemoryDiagnosticBatchStore(10);
$original = $this->snapshot('execution-1', 'http-request');
$duplicate = $this->snapshot('execution-1', 'cli-command');

Expand All @@ -94,6 +155,28 @@ public function testDuplicateIdentifierIsRejectedAndDoesNotReplaceOriginal(): vo
self::assertSame(array($original), $store->latest(10));
}

public function testDuplicateAtCapacityDoesNotEvictOrMutateRetainedSnapshots(): void
{
$store = new InMemoryDiagnosticBatchStore(2);
$first = $this->snapshot('execution-1', 'http-request');
$second = $this->snapshot('execution-2', 'queue-message');
$duplicate = $this->snapshot('execution-1', 'cli-command');

$store->save($first);
$store->save($second);

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($first, $store->find('execution-1'));
self::assertSame($second, $store->find('execution-2'));
self::assertSame(array($second, $first), $store->latest(10));
}

private function snapshot(string $identifier, string $kind = 'http-request'): DiagnosticBatchSnapshot
{
return new DiagnosticBatchSnapshot($identifier, $kind, array(), 0);
Expand Down
Loading