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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

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

### Repository

Expand Down
8 changes: 6 additions & 2 deletions packages/insight/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,15 @@ 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
- `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. It is not a persistent runtime store.
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.

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

## Requirements

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

## Current Limitations

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

## Licence

Expand Down
212 changes: 212 additions & 0 deletions packages/insight/src/Storage/DiagnosticBatchSnapshotCodec.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
<?php

declare(strict_types=1);

namespace Evolve\Insight\Storage;

final class DiagnosticBatchSnapshotCodec
{
private const int VERSION = 1;

private const array PAYLOAD_KEYS = array(
'version',
'execution_identifier',
'execution_kind',
'observations',
'dropped_observation_count',
);

private const array OBSERVATION_KEYS = array(
'type',
'outcome',
'error_type',
'reuse_decision',
);

private const array EXECUTION_KINDS = array(
'http-request',
'queue-message',
'scheduled-job',
'cli-command',
'worker-task',
);

private const array OBSERVATION_TYPES = array(
'execution-started',
'handler-completed',
'scope-close-started',
'scope-close-completed',
'quarantine-required',
'execution-completed',
);

private const array OBSERVATION_OUTCOMES = array(
'succeeded',
'failed',
);

private const array REUSE_DECISIONS = array(
'reusable',
'quarantine-required',
);

public function encode(DiagnosticBatchSnapshot $snapshot): string
{
$this->requireAllowedValue($snapshot->executionKind(), self::EXECUTION_KINDS, 'Execution kind');

return json_encode(
array(
'version' => self::VERSION,
'execution_identifier' => $snapshot->executionIdentifier(),
'execution_kind' => $snapshot->executionKind(),
'observations' => array_map(
fn (DiagnosticObservationSnapshot $observation): array => $this->encodeObservation($observation),
$snapshot->observations(),
),
'dropped_observation_count' => $snapshot->droppedObservationCount(),
),
JSON_THROW_ON_ERROR,
);
}

/**
* @return array{type: string, outcome: ?string, error_type: ?string, reuse_decision: ?string}
*/
private function encodeObservation(DiagnosticObservationSnapshot $observation): array
{
$this->requireAllowedValue($observation->type(), self::OBSERVATION_TYPES, 'Observation type');

$outcome = $observation->outcome();
if ($outcome !== null) {
$this->requireAllowedValue($outcome, self::OBSERVATION_OUTCOMES, 'Observation outcome');
}

$reuseDecision = $observation->reuseDecision();
if ($reuseDecision !== null) {
$this->requireAllowedValue($reuseDecision, self::REUSE_DECISIONS, 'Process reuse decision');
}

return array(
'type' => $observation->type(),
'outcome' => $outcome,
'error_type' => $observation->errorType(),
'reuse_decision' => $reuseDecision,
);
}

public function decode(string $payload): DiagnosticBatchSnapshot
{
try {
$decoded = json_decode($payload, true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException $exception) {
throw new \InvalidArgumentException('Diagnostic batch snapshot payload must be valid JSON.', 0, $exception);
}

if (!is_array($decoded) || array_is_list($decoded)) {
throw new \InvalidArgumentException('Diagnostic batch snapshot payload must be a JSON object.');
}

$this->requireKeys($decoded, self::PAYLOAD_KEYS, 'Diagnostic batch snapshot payload');

if ($decoded['version'] !== self::VERSION) {
throw new \InvalidArgumentException('Diagnostic batch snapshot payload version is not supported.');
}

$identifier = $this->requireString($decoded['execution_identifier'], 'Execution identifier');
$kind = $this->requireString($decoded['execution_kind'], 'Execution kind');
$this->requireAllowedValue($kind, self::EXECUTION_KINDS, 'Execution kind');

if (!is_array($decoded['observations']) || !array_is_list($decoded['observations'])) {
throw new \InvalidArgumentException('Diagnostic batch snapshot observations must be a list.');
}

$droppedObservationCount = $decoded['dropped_observation_count'];
if (!is_int($droppedObservationCount)) {
throw new \InvalidArgumentException('Dropped observation count must be an integer.');
}

return new DiagnosticBatchSnapshot(
$identifier,
$kind,
array_map(
fn (mixed $observation): DiagnosticObservationSnapshot => $this->decodeObservation($observation),
$decoded['observations'],
),
$droppedObservationCount,
);
}

private function decodeObservation(mixed $observation): DiagnosticObservationSnapshot
{
if (!is_array($observation) || array_is_list($observation)) {
throw new \InvalidArgumentException('Diagnostic observation snapshot payload must be a JSON object.');
}

$this->requireKeys($observation, self::OBSERVATION_KEYS, 'Diagnostic observation snapshot payload');

$type = $this->requireString($observation['type'], 'Observation type');
$this->requireAllowedValue($type, self::OBSERVATION_TYPES, 'Observation type');

$outcome = $this->requireNullableString($observation['outcome'], 'Observation outcome');
if ($outcome !== null) {
$this->requireAllowedValue($outcome, self::OBSERVATION_OUTCOMES, 'Observation outcome');
}

$reuseDecision = $this->requireNullableString($observation['reuse_decision'], 'Process reuse decision');
if ($reuseDecision !== null) {
$this->requireAllowedValue($reuseDecision, self::REUSE_DECISIONS, 'Process reuse decision');
}

return new DiagnosticObservationSnapshot(
$type,
$outcome,
$this->requireNullableString($observation['error_type'], 'Observation error type'),
$reuseDecision,
);
}

/**
* @param array<mixed> $payload
* @param list<string> $expectedKeys
*/
private function requireKeys(array $payload, array $expectedKeys, string $context): void
{
$actualKeys = array_keys($payload);
sort($actualKeys);

$sortedExpectedKeys = $expectedKeys;
sort($sortedExpectedKeys);

if ($actualKeys !== $sortedExpectedKeys) {
throw new \InvalidArgumentException($context . ' has an invalid schema.');
}
}

private function requireString(mixed $value, string $field): string
{
if (!is_string($value)) {
throw new \InvalidArgumentException($field . ' must be a string.');
}

return $value;
}

private function requireNullableString(mixed $value, string $field): ?string
{
if ($value === null) {
return null;
}

return $this->requireString($value, $field);
}

/**
* @param list<string> $allowedValues
*/
private function requireAllowedValue(string $value, array $allowedValues, string $field): void
{
if (!in_array($value, $allowedValues, true)) {
throw new \InvalidArgumentException($field . ' is not supported by this diagnostic snapshot format.');
}
}
}
149 changes: 149 additions & 0 deletions packages/insight/src/Storage/SqliteDiagnosticBatchStore.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
<?php

declare(strict_types=1);

namespace Evolve\Insight\Storage;

final class SqliteDiagnosticBatchStore implements DiagnosticBatchStore
{
private const string TABLE = 'insight_diagnostic_batches';

private DiagnosticBatchSnapshotCodec $codec;

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

$this->codec = $codec ?? new DiagnosticBatchSnapshotCodec();
$this->createSchema();
}

public function save(DiagnosticBatchSnapshot $snapshot): void
{
$identifier = $snapshot->executionIdentifier();

if ($this->identifierExists($identifier)) {
throw new \LogicException('Diagnostic batch snapshot already exists for execution identifier.');
}

$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),
));
}

public function find(string $executionIdentifier): ?DiagnosticBatchSnapshot
{
$statement = $this->prepare(
'SELECT execution_identifier, snapshot_payload FROM ' . self::TABLE . ' WHERE execution_identifier = :execution_identifier'
);
$this->execute($statement, array('execution_identifier' => $executionIdentifier));

$row = $statement->fetch(\PDO::FETCH_ASSOC);
if ($row === false) {
return null;
}

return $this->decodeRow($row);
}

public function latest(int $limit): array
{
if ($limit <= 0) {
throw new \InvalidArgumentException('Latest limit must be positive.');
}

$statement = $this->prepare(
'SELECT execution_identifier, snapshot_payload FROM ' . self::TABLE . ' ORDER BY sequence DESC LIMIT :limit'
);
$statement->bindValue('limit', $limit, \PDO::PARAM_INT);

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

$snapshots = array();

while (($row = $statement->fetch(\PDO::FETCH_ASSOC)) !== false) {
$snapshots[] = $this->decodeRow($row);
}

return $snapshots;
}

private function createSchema(): void
{
$this->exec(
'CREATE TABLE IF NOT EXISTS ' . self::TABLE . ' (
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
execution_identifier TEXT NOT NULL UNIQUE,
snapshot_payload TEXT NOT NULL
)'
);
$this->exec(
'CREATE INDEX IF NOT EXISTS insight_diagnostic_batches_execution_identifier_idx
ON ' . self::TABLE . ' (execution_identifier)'
);
}

private function identifierExists(string $identifier): bool
{
$statement = $this->prepare(
'SELECT 1 FROM ' . self::TABLE . ' WHERE execution_identifier = :execution_identifier'
);
$this->execute($statement, array('execution_identifier' => $identifier));

return $statement->fetchColumn() !== false;
}

/**
* @param array<string, mixed> $row
*/
private function decodeRow(array $row): DiagnosticBatchSnapshot
{
if (!is_string($row['execution_identifier'] ?? null) || !is_string($row['snapshot_payload'] ?? null)) {
throw new \UnexpectedValueException('Persisted diagnostic batch row has an invalid shape.');
}

$snapshot = $this->codec->decode($row['snapshot_payload']);

if ($snapshot->executionIdentifier() !== $row['execution_identifier']) {
throw new \UnexpectedValueException('Persisted diagnostic batch identifier does not match its index.');
}

return $snapshot;
}

private function exec(string $sql): void
{
if ($this->pdo->exec($sql) === false) {
throw new \RuntimeException('Failed to initialize SQLite diagnostic batch store schema.');
}
}

private function prepare(string $sql): \PDOStatement
{
$statement = $this->pdo->prepare($sql);

if (!$statement instanceof \PDOStatement) {
throw new \RuntimeException('Failed to prepare SQLite diagnostic batch store statement.');
}

return $statement;
}

/**
* @param array<string, scalar|null> $parameters
*/
private function execute(\PDOStatement $statement, array $parameters): void
{
if (!$statement->execute($parameters)) {
throw new \RuntimeException('Failed to execute SQLite diagnostic batch store statement.');
}
}
}
Loading