From e5e72ce6e66b1e005b2c4e21c1276e88f3be880e Mon Sep 17 00:00:00 2001 From: Renn F Date: Wed, 14 Oct 2026 22:47:06 +0200 Subject: [PATCH 1/2] fix(buffer): persist to Redis off the request path and include the cause in the startup encryption error --- .github/coverage-runner.php | 6 +- AGENTS.md | 6 +- README.md | 2 +- bin/test_agent.php | 116 ++++++++++++++++++++ src/.agents/skills/guard-agent-php/SKILL.md | 2 +- src/EventBuffer/EventBuffer.php | 102 ++++++++++++----- src/GuardAgent.php | 7 ++ src/Transport/HttpTransport.php | 5 +- 8 files changed, 209 insertions(+), 37 deletions(-) diff --git a/.github/coverage-runner.php b/.github/coverage-runner.php index e366126..4a96d6b 100644 --- a/.github/coverage-runner.php +++ b/.github/coverage-runner.php @@ -35,14 +35,14 @@ // of which throw) each catch every client Throwable inside RedisHandler // and degrade to a return value, so no Throwable can reach the buffer's // own catch arms. - 'src/EventBuffer/EventBuffer.php' => [196, 197, 271, 272, 289, 290, 377, 378, 425, 426, 595, 596], + 'src/EventBuffer/EventBuffer.php' => [198, 199, 307, 308, 325, 326, 641, 642], // stop()'s close catch: GuardAgent::$redisHandler is the final // RedisHandler (initializeRedis hard-types it) whose close() catches // every Throwable (252-253). asMetadataArray/asStringArray fallbacks: // the inputs are SecurityEvent::$metadata / SecurityMetric::$tags array // properties passed through HeadersRedactor::sanitize, which returns an // array for an array input at the top depth (778, 789). - 'src/GuardAgent.php' => [252, 253, 778, 789], + 'src/GuardAgent.php' => [252, 253, 785, 796], // read()/write() catch arms over @-suppressed filesystem calls, which // return false instead of throwing (76-77, 79, 95-96): is_file($path) // true implies a non-empty path, and the PHP 8 ValueErrors in this @@ -63,7 +63,7 @@ // toWire() casts metadata/tags to objects (SecurityEvent.php:155, // SecurityMetric.php:101) and makeRequest's payload always comes from // buildBatchWire mapping those toWire() outputs (512-515, 522-525). - 'src/Transport/HttpTransport.php' => [512, 513, 514, 515, 522, 523, 524, 525], + 'src/Transport/HttpTransport.php' => [515, 516, 517, 518, 525, 526, 527, 528], // json_encode of a finite float with JSON_THROW_ON_ERROR cannot fail: // the is_finite guard above rejects Inf/NaN before this call is reached // (asserted by the canonical json tests) and a finite float scalar has diff --git a/AGENTS.md b/AGENTS.md index 1328761..d3ab232 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ All of `src/`, namespace `RenzoFranceschini\GuardAgent`: - `GuardAgent.php`: the client. `__construct(AgentConfig|array $config, ?TransportInterface $transport = null)` (the second parameter is the test seam). `start()` / `stop()` / `close()` lifecycle (start idempotent and may throw; stop idempotent and never throws); `sendEvent()` / `sendMetric()` ingest path (never throw, normalize + redact + buffer); `flushBuffer()` (never throws); `tick()` and `flushIfNeeded()` (host-driven loop, see Runtime model below); `getDynamicRules()` (TTL-cached rules fetch, never throws); `getStatus()` / `getStats()` / `healthCheck()`. Per-kind failure streaks with backoff gates (`eventsRetryAfter` / `metricsRetryAfter`), `flushConsecutiveFailures` / `statusConsecutiveFailures` / `rulesConsecutiveFailures` loop counters, `lastStatusPushOk`, `rulesFetched` / `cachedRules` / `rulesLastUpdate`. - `Config/AgentConfig.php`: readonly resolved config (camelCase, mirroring the TS port naming; defaults identical to the Python agent). `Config/AgentConfigResolver.php`: `resolve(array $input)` (accepts camelCase and snake_case keys), `validate()` (returns the error list), `normalizeEndpoint()` (strips trailing slashes and a legacy `/api/v1` suffix with a one-time warning, mirroring guard_agent/models.py:176-205). `Config/BufferOverflowPolicy.php`: `drop` / `block` / `raise` enum. `Config/RedisConfig.php`: url, keyPrefix (`guard:agent`), password, db, commandTimeoutMs (5000). -- `EventBuffer/EventBuffer.php`: per-kind bounded queues with `SplObjectStorage` key maps; overflow policies; `flushEventsWithKeys()` / `flushMetricsWithKeys()` / `requeueEventsInMemory()` / `requeueMetricsInMemory()` (front requeue, tail eviction returns the evicted keys); `confirmEventRedisKeys()` / `confirmMetricRedisKeys()`; persist-on-enqueue with TTL 3600; `initializeRedis()` reloads persisted items at startup; `clearBuffer()`; `atHighWatermark()`. +- `EventBuffer/EventBuffer.php`: per-kind bounded queues with `SplObjectStorage` key maps; overflow policies; `flushEventsWithKeys()` / `flushMetricsWithKeys()` / `requeueEventsInMemory()` / `requeueMetricsInMemory()` (front requeue, tail eviction returns the evicted keys); `confirmEventRedisKeys()` / `confirmMetricRedisKeys()`; `persistPending()` (writes unkeyed buffered items, TTL 3600; never called from add; run by `flushBuffer()` after the send and by `flushIfNeeded()` when no flush is due); `initializeRedis()` reloads persisted items at startup; `clearBuffer()`; `atHighWatermark()`. - `Transport/HttpTransport.php`: cURL implementation of `TransportInterface`. Retry loop (`retryAttempts + 1` tries, local `RateLimiter` 100/60, `CircuitBreaker` 5 failures/60s), response classification, gzip compression, `Signer` HMAC, 413 split-or-drop, permanent-rejection drops, `RetryAfter` parsing (default 60s, honored up to 300s), `BatchId`, response-body summaries. `Transport/TransportInterface.php`: the seam (`sendEvents`/`sendMetrics`/`sendStatus` return bool; true = accepted or intentionally dropped, false = transient, caller requeues; `fetchDynamicRules()` returns `?DynamicRules` and never throws). `getWithRetry()` implements the GET path for `GET /api/v1/rules` with the same retry/circuit-breaker/Retry-After machinery as the POST loop (mirrors `_get_with_retry`, `_transport_send.py:238-289`). `Transport/CircuitBreaker.php`, `Transport/RateLimiter.php`, `Transport/Signer.php`. - `Persistence/RedisClientInterface.php`: the client seam (ping/get/set/delete/keys/close). `Persistence/StreamRedisClient.php`: dependency-free RESP2 client over `stream_socket_client` (PING, AUTH, SELECT, GET, SET EX, DEL, KEYS; command timeout via `stream_set_timeout`). `Persistence/PredisRedisClient.php` and `Persistence/ExtRedisClient.php`: optional adapters over injected `\Predis\ClientInterface`-shaped and `\Redis` instances. `Persistence/RedisHandler.php`: namespaced facade (`{keyPrefix}:{namespace}:{short}`) with the Go port's fail-open policy: 3 consecutive write failures pause writes for 30s. - `Model/SecurityEvent.php`, `Model/SecurityMetric.php`, `Model/AgentStatus.php`: readonly wire models with `normalize()` (camelCase or snake_case input, `InvalidEventException` on bad input) and `toWire()` (snake_case, ISO-8601 UTC with milliseconds). `Model/KnownTypes.php`: the advisory event-type list and the enforced metric-type list. `Model/DynamicRules.php`: the SaaS rules surface (mirrors DynamicRules, guard_agent/models.py:250-324, snake_case wire keys with camelCase properties, `normalize()` raising `InvalidRulesException`; `ttl` drives the client cache, `blocked_cloud_providers` deduped like the Python set, `ge=1` enforced only on the `auto_ban_*` fields). `Model/WireFormat.php`: timestamp parsing (DateTimeInterface, epoch SECONDS, or parseable string) and `isoUtc()`. @@ -104,7 +104,7 @@ Every field except `apiKey` is optional; `AgentConfigResolver::resolve()` fills - **At-least-once handshake**: drain (keys aligned with items) -> send -> on success confirm (delete Redis records); on failure requeue at the FRONT in original order, retain Redis records, arm the per-kind gate for `min(flushInterval * 2^(streak - 1), 300)` seconds. Under pressure the requeue evicts the TAIL (newest items) and the caller confirms those evicted keys so nothing orphans. - **Response classification** (mirroring `_handle_response`): 200 with a JSON dict is evaluated (`success: false` or non-empty `errors[]` means partial failure -> whole batch requeued); 200 with an unparseable body is retried; 200 non-dict and 201 are accepted; 429 raises `RateLimitedException` with Retry-After (default 60s, honored up to 300s); 401/403 are generic retried errors; 400/404/422 are `PermanentClientException` (batch dropped, counted, never retried); 413 is `PayloadTooLargeException` (split in half recursively; a singleton that still exceeds the cap is dropped); 5xx retry with backoff; any other 4xx is logged and retried. - **Circuit breaker**: 5 consecutive non-permanent failures open it for 60s, then HALF_OPEN probes. 400/404/413/422 are exempt (batch-level rejections, not health signals); 429 and curl errors count. -- **Persistence**: persist-on-enqueue under a globally-unique key `{prefix}:agent_events:event__<8hex>` (or `agent_metrics`), TTL 3600s. Startup reload restores items oldest-first; corrupt or missing records are warned and skipped (the TTL reclaims them). Every Redis failure is fail-open: logged, counted in `redisPersistFailures` (`durabilityDegraded`), and after 3 consecutive write failures writes pause for 30s. +- **Persistence**: persisted by `persistPending()` (end of `flushBuffer()`, or `flushIfNeeded()`/`tick()` between flushes, never on add) under a globally-unique key `{prefix}:agent_events:event__<8hex>` (or `agent_metrics`), TTL 3600s. Startup reload restores items oldest-first; corrupt or missing records are warned and skipped (the TTL reclaims them). Every Redis failure is fail-open: logged, counted in `redisPersistFailures` (`durabilityDegraded`), and after 3 consecutive write failures writes pause for 30s. - **Degraded status**: circuit OPEN, or buffer >= 90 percent occupancy, or lifetime failure rate > 10 percent. `healthCheck()` additionally fails at >= 95 percent occupancy or > 50 percent failure rate, and when not running. ## Development Commands @@ -124,7 +124,7 @@ Docker equivalents are in Quick Start. There is no Makefile (the sibling PHP rep - `REDIS_HOST` controls the persistence leg: `REDIS_HOST=0` skips the real-Redis integration (unit-only), any hostname runs it against port 6379. The fake-Redis handshake tests always run. - The mock-server integration smoke spawns `php -S` with `tests/mock_server.php`, which mirrors the ingestion contract: size-based 413 (decompressed body > 262144 bytes), signature verification over the uncompressed body with an optional `requireSigned` mode, scripted per-path responses from a control file (status, body, Retry-After), and a JSON state log the tests assert against. - Pinned HMAC vector: `Signer::signPayload('{"hello":"world"}', 'test-secret')` must equal `v1=84cc33df716ed0b0598f07437c94069ace3730358778a592bd6bbd1423d111f3`. -- Coverage expectations when editing: config validation (all constraint branches), buffer overflow policies and requeue eviction, the handshake order (drain -> send -> confirm/requeue/gate), retry classification (200 partial, 429, 400, 401, 413 split and singleton drop, 5xx, dead endpoint), breaker open/exempt/half-open, persistence (persist-on-enqueue, TTL, reload, confirm deletes, fail-open cooldown), and the smoke contract assertions (headers, gzip, signature, endpoints). +- Coverage expectations when editing: config validation (all constraint branches), buffer overflow policies and requeue eviction, the handshake order (drain -> send -> confirm/requeue/gate), retry classification (200 partial, 429, 400, 401, 413 split and singleton drop, 5xx, dead endpoint), breaker open/exempt/half-open, persistence (deferred persistPending, TTL, reload, confirm deletes, fail-open cooldown), and the smoke contract assertions (headers, gzip, signature, endpoints). - `vendor/` is never committed; `composer.lock` is (zero-dependency lock, generated with the `composer:2` image). ## Best Practices diff --git a/README.md b/README.md index 6fa98f8..3f1fb1e 100644 --- a/README.md +++ b/README.md @@ -218,7 +218,7 @@ When `projectEncryptionKey` is set (a urlsafe-base64-encoded 256-bit key issued ## Persistence -With `redis` configured, every accepted item is written to Redis under a globally-unique key (`{prefix}:agent_events:event__<8hex>`, TTL 3600s) on enqueue; on `start()` the buffer reloads whatever a previous process left behind. Every Redis failure is fail-open: logged, counted, and after 3 consecutive write failures paused for a 30s cooldown, so an unhealthy Redis cannot tax the request path. The TTL is the backstop: at worst a lost confirmation duplicates a reload, it never loses data. +With `redis` configured, every accepted item is written to Redis under a globally-unique key (`{prefix}:agent_events:event__<8hex>`, TTL 3600s) by `persistPending()`, which `flushBuffer()` runs after its send attempt (so delivered items are never written) and `flushIfNeeded()`/`tick()` run between flushes; adding an item never waits on Redis. A process that dies before the next flush or tick loses what was buffered since; on `start()` the buffer reloads whatever a previous process left behind. Every Redis failure is fail-open: logged, counted, and after 3 consecutive write failures paused for a 30s cooldown, so an unhealthy Redis cannot tax the request path. The TTL is the backstop: at worst a lost confirmation duplicates a reload, it never loses data. ## Documentation diff --git a/bin/test_agent.php b/bin/test_agent.php index bd704de..d5d426e 100644 --- a/bin/test_agent.php +++ b/bin/test_agent.php @@ -1275,6 +1275,7 @@ function assertStaticOnlyClass(T $t, string $class): void $buffer->initializeRedis(new RedisHandler($fake, $prefix, new SilentLogger())); $resident = makeEvent(['event_type' => 'resident']); $buffer->addEvent($resident); +$buffer->persistPending(); [$drained, $residentKeys] = $buffer->flushEventsWithKeys(); $t->same(1, count($fake->storage), 'enqueue persisted the resident event'); $requeue = [ @@ -1295,6 +1296,8 @@ function assertStaticOnlyClass(T $t, string $class): void $buffer->initializeRedis(new RedisHandler($fake, $prefix, new SilentLogger())); $buffer->addEvent(makeEvent(['event_type' => 'a'])); $buffer->addMetric(makeMetric()); +$buffer->persistPending(); +$t->ok($fake->storage !== [], 'clearBuffer precondition: records persisted'); $buffer->clearBuffer(); $t->same(0, $buffer->getBufferSize(), 'clearBuffer empties memory'); $t->same([], $fake->storage, 'clearBuffer wipes persisted records'); @@ -1303,6 +1306,7 @@ function assertStaticOnlyClass(T $t, string $class): void $buffer = new EventBuffer(makeConfig()); $buffer->initializeRedis(new RedisHandler($fake, $prefix, new SilentLogger())); $buffer->addMetric(makeMetric(['metric_type' => 'error_rate', 'value' => 0.25])); +$buffer->persistPending(); $metricKeys = $fake->keysContaining(':agent_metrics:metric_'); $t->same(1, count($metricKeys), 'metric persisted under the agent_metrics namespace'); $t->same(3600, $fake->ttls[$metricKeys[0]] ?? null, 'metric persistence TTL is 3600 seconds'); @@ -1562,6 +1566,7 @@ function assertStaticOnlyClass(T $t, string $class): void $agent = makeAgent(['flushInterval' => 30], $transport); $agent->initializeRedis(new RedisHandler($fake, $prefix, new SilentLogger())); $agent->sendEvent(makeEvent(['event_type' => 'a'])); +$agent->buffer->persistPending(); $eventKeys = $fake->keysContaining(':agent_events:event_'); $t->same(1, count($eventKeys), 'event persisted on enqueue'); $t->ok(str_starts_with($eventKeys[0], $prefix . ':agent_events:event_'), 'persistence key is prefixed and globally unique'); @@ -1569,6 +1574,7 @@ function assertStaticOnlyClass(T $t, string $class): void $fake->failWrites(true); $agent->sendEvent(makeEvent(['event_type' => 'b'])); +$agent->buffer->persistPending(); $t->same(1, $agent->buffer->getStats()['redisPersistFailures'], 'persist failure counted'); $t->ok($agent->buffer->getStats()['durabilityDegraded'], 'durability reported degraded after a persist failure'); $fake->failWrites(false); @@ -1589,6 +1595,7 @@ function assertStaticOnlyClass(T $t, string $class): void $agentA = makeAgent(['flushInterval' => 30], new RecordingTransport()); $agentA->initializeRedis($handler); $agentA->sendEvent(makeEvent(['event_type' => 'persisted'])); +$agentA->buffer->persistPending(); $t->same(1, count($fake->storage), 'agent A persisted the event'); $transportB = new RecordingTransport(); $agentB = makeAgent(['flushInterval' => 30], $transportB); @@ -1655,6 +1662,7 @@ function assertStaticOnlyClass(T $t, string $class): void $agentA = new GuardAgent(makeConfig(['flushInterval' => 30, 'installId' => 'install-real']), $transportA); $agentA->initializeRedis(new RedisHandler($realClient, $namespace, new SilentLogger())); $agentA->sendEvent(makeEvent(['event_type' => 'durable'])); + $agentA->buffer->persistPending(); $t->same(1, count($realClient->keys($namespace . ':agent_events:*')), 'event persisted to the real redis on enqueue'); $transportB = new RecordingTransport(); @@ -2725,9 +2733,11 @@ static function (string $stage, Throwable $error, array $context) use (&$hookCal $throwingBuffer = new EventBuffer(makeConfig()); $throwingBuffer->initializeRedis(new RedisHandler(new ThrowingRedisClient(), 'gatest', new SilentLogger())); $throwingBuffer->addEvent(makeEvent(['event_type' => 'a'])); +$throwingBuffer->persistPending(); $t->same(1, $throwingBuffer->getStats()['redisPersistFailures'], 'a throwing persist counts a failure (events)'); $t->same('', $throwingBuffer->flushEventsWithKeys()[1][0], 'a throwing persist stores no key'); $throwingBuffer->addMetric(makeMetric()); +$throwingBuffer->persistPending(); $t->same(2, $throwingBuffer->getStats()['redisPersistFailures'], 'a throwing persist counts a failure (metrics)'); // Persist rejections (SET not OK) count too. @@ -2736,6 +2746,7 @@ static function (string $stage, Throwable $error, array $context) use (&$hookCal $rejectBuffer = new EventBuffer(makeConfig()); $rejectBuffer->initializeRedis(new RedisHandler($rejectFake, 'gatest', new SilentLogger())); $rejectBuffer->addMetric(makeMetric()); +$rejectBuffer->persistPending(); $t->same(1, $rejectBuffer->getStats()['redisPersistFailures'], 'a rejected metric persist counts a failure'); // Startup load: valid seeds, missing data, corrupt json, invalid payloads, @@ -2807,6 +2818,7 @@ static function (string $stage, Throwable $error, array $context) use (&$hookCal $keyedBuffer->initializeRedis(new RedisHandler($keyedRequeueFake, 'gatest-keyed', new SilentLogger())); $keyedBuffer->addMetric(makeMetric(['value' => 1])); $keyedBuffer->addMetric(makeMetric(['value' => 2])); +$keyedBuffer->persistPending(); [$keyedMetrics, $keyedKeys] = $keyedBuffer->flushMetricsWithKeys(); $requeueMetricEvicted = $keyedBuffer->requeueMetricsInMemory( [makeMetric(['value' => 4]), makeMetric(['value' => 3]), $keyedMetrics[1], $keyedMetrics[0]], @@ -2820,8 +2832,10 @@ static function (string $stage, Throwable $error, array $context) use (&$hookCal $utf8Buffer = new EventBuffer(makeConfig()); $utf8Buffer->initializeRedis(new RedisHandler($utf8Fake, 'gatest-utf8', new SilentLogger())); $utf8Buffer->addEvent(makeEvent(['metadata' => ['bad' => "\xB1\x31"]])); +$utf8Buffer->persistPending(); $t->same(1, $utf8Buffer->getStats()['redisPersistFailures'], 'an unencodable event payload counts a persist failure'); $utf8Buffer->addMetric(makeMetric(['tags' => ['bad' => "\xB1\x31"]])); +$utf8Buffer->persistPending(); $t->same(2, $utf8Buffer->getStats()['redisPersistFailures'], 'an unencodable metric payload counts a persist failure'); // Confirm paths without a handler and with delete failures. @@ -3283,4 +3297,106 @@ static function (string $stage, Throwable $error, array $context) use (&$hookCal $t->ok((bool) array_filter($logger->warnings, static fn (string $w): bool => str_contains($w, 'Redis persistence disabled')), 'the degraded connection is announced'); $agent->stop(); +// ===================================================================== +// Persistence off the request path +// ===================================================================== +$t->section('persistence off the request path'); + +$prefix = 'guard:agent:test:' . bin2hex(random_bytes(4)); +$fake = new FakeRedisClient(); +$transport = new RecordingTransport(); +$agent = makeAgent(['flushInterval' => 30], $transport); +$agent->initializeRedis(new RedisHandler($fake, $prefix, new SilentLogger())); +$agent->sendEvent(makeEvent(['event_type' => 'a'])); +$agent->sendEvent(makeEvent(['event_type' => 'b'])); +$agent->sendMetric(makeMetric()); +$t->same([], $fake->storage, 'emitting events and metrics performs no Redis write'); + +// Delivered items are never written: no orphaned keys. +$agent->flushBuffer(); +$t->same(2, $agent->eventsSent, 'flush delivered the events'); +$t->same([], $fake->storage, 'items sent at the flush point leave no Redis keys'); + +// Requeued items are persisted after the failed send and keep their keys. +$transport->eventOutcomes = [false, true]; +$agent->sendEvent(makeEvent(['event_type' => 'c'])); +$agent->sendEvent(makeEvent(['event_type' => 'd'])); +$agent->flushBuffer(); +$t->same(2, count($fake->keysContaining(':agent_events:event_')), 'a failed send persists the requeued events at the flush point'); +$keysBefore = $fake->keysContaining(':agent_events:event_'); +sort($keysBefore); +refl($agent, 'eventsRetryAfter')->setValue($agent, 0.0); +$agent->buffer->persistPending(); +$keysAfter = $fake->keysContaining(':agent_events:event_'); +sort($keysAfter); +$t->same($keysBefore, $keysAfter, 'requeued items keep their keys (no duplicate writes)'); +$agent->flushBuffer(); +$t->same([], $fake->storage, 'a later successful send deletes the requeued keys'); + +// Buffered items are written at the persistence point (tick between flushes). +$agent = makeAgent(['flushInterval' => 3600, 'bufferSize' => 100], new RecordingTransport()); +$fake = new FakeRedisClient(); +$agent->initializeRedis(new RedisHandler($fake, $prefix, new SilentLogger())); +$agent->sendEvent(makeEvent(['event_type' => 'e'])); +$agent->sendEvent(makeEvent(['event_type' => 'f'])); +$t->same([], $fake->storage, 'nothing written before the persistence point'); +$agent->buffer->lastFlushTime = microtime(true); +$agent->flushIfNeeded(); +$t->same(2, count($fake->storage), 'flushIfNeeded persists all pending items even when no flush is due'); +$agent->flushIfNeeded(); +$t->same(2, count($fake->storage), 'persisting twice does not rewrite items'); + +// A dropped (evicted) pending item is never written. +$fake = new FakeRedisClient(); +$buffer = new EventBuffer(makeConfig(['bufferSize' => 1])); +$buffer->initializeRedis(new RedisHandler($fake, $prefix, new SilentLogger())); +$buffer->addEvent(makeEvent(['event_type' => 'old'])); +$buffer->addEvent(makeEvent(['event_type' => 'new'])); +$buffer->persistPending(); +$t->same(1, count($fake->storage), 'an evicted pending event is not persisted'); + +// A persisted item dropped under pressure is confirmed (key deleted, no orphan). +$fake = new FakeRedisClient(); +$buffer = new EventBuffer(makeConfig(['bufferSize' => 1])); +$buffer->initializeRedis(new RedisHandler($fake, $prefix, new SilentLogger())); +$buffer->addEvent(makeEvent(['event_type' => 'old'])); +$buffer->addMetric(makeMetric(['value' => 1])); +$buffer->persistPending(); +$t->same(2, count($fake->storage), 'precondition: event and metric persisted'); +$buffer->addEvent(makeEvent(['event_type' => 'new'])); +$buffer->addMetric(makeMetric(['value' => 2])); +$t->same([], $fake->storage, 'dropping persisted items deletes their keys'); + +// A failed batch counts failures and respects the cooldown. +$fake = new FakeRedisClient(); +$fake->failWrites(true); +$buffer = new EventBuffer(makeConfig()); +$handler = new RedisHandler($fake, $prefix, new SilentLogger()); +$buffer->initializeRedis($handler); +for ($i = 0; $i < 5; $i++) { + $buffer->addEvent(makeEvent(['event_type' => 'x' . $i])); +} +$t->same(0, $buffer->getStats()['redisPersistFailures'], 'no failure is counted before the persistence point'); +$buffer->persistPending(); +$t->same(5, $buffer->getStats()['redisPersistFailures'], 'a failed batch counts one failure per item'); +$t->same(5, $handler->failureCount(), 'handler failure count matches (3 real failures plus 2 cooldown rejections)'); +$fake->failWrites(false); +$buffer->addEvent(makeEvent(['event_type' => 'y'])); +$buffer->persistPending(); +$t->same([], $fake->storage, 'the cooldown still blocks writes after 3 consecutive failures'); + +// The startup encryption error includes the cause, still failing closed. +$caught = null; +try { + (new HttpTransport(makeConfig([ + 'endpoint' => 'https://ingest.example.test', + 'projectEncryptionKey' => 'not-a-valid-key', + ])))->initialize(); +} catch (EncryptionConfigException $error) { + $caught = $error; +} +$t->ok($caught !== null, 'invalid encryption key still fails startup'); +$t->ok($caught !== null && str_contains($caught->getMessage(), 'refusing plaintext fallback (cause: '), 'startup error message carries the cause'); +$t->ok($caught !== null && $caught->getPrevious() !== null && str_contains($caught->getMessage(), $caught->getPrevious()->getMessage()), 'cause text equals the chained exception message'); + $t->finish(); diff --git a/src/.agents/skills/guard-agent-php/SKILL.md b/src/.agents/skills/guard-agent-php/SKILL.md index 8fc666e..d7e03a2 100644 --- a/src/.agents/skills/guard-agent-php/SKILL.md +++ b/src/.agents/skills/guard-agent-php/SKILL.md @@ -11,7 +11,7 @@ description: Use when an agent works in rennf93/guard-agent-php or embeds the PH - Entry point: `new GuardAgent(AgentConfigResolver::resolve([...]))`. `start()` loads crash-recovery state; `sendEvent()` / `sendMetric()` never throw; `flushBuffer()` never throws; `tick()` is the host-driven loop body (flush + status cadence); `stop()` is idempotent and performs the final flush. - Failure isolation: host-facing methods never throw into the request path. The only opt-in exception is `bufferOverflowPolicy: 'block'`, which drains inline. - Wire contract: `POST /api/v1/events|metrics|status`, `X-API-Key` + `X-Project-Id` + `X-Agent-Install-Id`, gzip above `compressionThreshold`, `X-Payload-Signature: v1=` (the server verifies post-decompression). -- Semantics: per-kind backoff `min(flushInterval * 2^(streak - 1), 300s)`; circuit breaker 5 failures/60s with 400/404/413/422 exempt; 429 Retry-After honored to 300s; Redis persistence TTL 3600s, persist-on-enqueue, startup reload, fail-open with a 30s cooldown after 3 write failures. +- Semantics: per-kind backoff `min(flushInterval * 2^(streak - 1), 300s)`; circuit breaker 5 failures/60s with 400/404/413/422 exempt; 429 Retry-After honored to 300s; Redis persistence TTL 3600s, deferred persist (persistPending at flush/tick, never on add), startup reload, fail-open with a 30s cooldown after 3 write failures. - Commands: `composer lint` (php -l sweep over `src` and `bin`), `composer test` (`php bin/test_agent.php`). CI runs the matrix php 8.2/8.3/8.4 with `REDIS_HOST=127.0.0.1`, plus `composer audit`. ## Installation diff --git a/src/EventBuffer/EventBuffer.php b/src/EventBuffer/EventBuffer.php index ecf0faf..549da03 100644 --- a/src/EventBuffer/EventBuffer.php +++ b/src/EventBuffer/EventBuffer.php @@ -65,6 +65,12 @@ final class EventBuffer /** SecurityMetric -> short Redis key ('' never stored). */ private \SplObjectStorage $metricKeys; + /** Events accepted but not yet written to Redis (see persistPending). */ + private \SplObjectStorage $pendingEventPersist; + + /** Metrics accepted but not yet written to Redis (see persistPending). */ + private \SplObjectStorage $pendingMetricPersist; + private ?RedisHandler $redisHandler = null; private readonly int $maxSize; @@ -98,6 +104,8 @@ public function __construct(private readonly AgentConfig $config) { $this->eventKeys = new \SplObjectStorage(); $this->metricKeys = new \SplObjectStorage(); + $this->pendingEventPersist = new \SplObjectStorage(); + $this->pendingMetricPersist = new \SplObjectStorage(); $this->maxSize = $config->bufferSize; $this->logger = $config->logger ?? new DefaultAgentLogger(); } @@ -118,15 +126,12 @@ public function redisHandler(): ?RedisHandler return $this->redisHandler; } - private function persistEventToRedis(SecurityEvent $event): ?string + private function persistEventToRedis(RedisHandler $redis, SecurityEvent $event): ?string { - if ($this->redisHandler === null) { - return null; - } try { $key = self::uniqueKey('event'); $serialized = Json::encode($event->toWire()); - if (!$this->redisHandler->setKey('agent_events', $key, $serialized, self::REDIS_PERSIST_TTL_SECONDS)) { + if (!$redis->setKey('agent_events', $key, $serialized, self::REDIS_PERSIST_TTL_SECONDS)) { $this->redisPersistFailures++; return null; @@ -141,15 +146,12 @@ private function persistEventToRedis(SecurityEvent $event): ?string } } - private function persistMetricToRedis(SecurityMetric $metric): ?string + private function persistMetricToRedis(RedisHandler $redis, SecurityMetric $metric): ?string { - if ($this->redisHandler === null) { - return null; - } try { $key = self::uniqueKey('metric'); $serialized = Json::encode($metric->toWire()); - if (!$this->redisHandler->setKey('agent_metrics', $key, $serialized, self::REDIS_PERSIST_TTL_SECONDS)) { + if (!$redis->setKey('agent_metrics', $key, $serialized, self::REDIS_PERSIST_TTL_SECONDS)) { $this->redisPersistFailures++; return null; @@ -256,6 +258,40 @@ private function loadOneMetricFromRedis(string $fullKey): void } } + /** + * Write every buffered item that has no Redis record yet. Persistence is + * deliberately NOT done when an item is added: that put the request path + * behind a Redis round trip (up to the command timeout while Redis is + * slow). The host calls this off the hot path (GuardAgent::flushBuffer + * after the send attempt, so items that were just delivered are never + * written, and tick() between flushes). A failed write is counted and + * feeds the RedisHandler cooldown, exactly as before. + */ + public function persistPending(): void + { + if ($this->redisHandler === null) { + return; + } + foreach ($this->events as $event) { + if (isset($this->pendingEventPersist[$event]) && !isset($this->eventKeys[$event])) { + $key = $this->persistEventToRedis($this->redisHandler, $event); + if ($key !== null) { + $this->eventKeys[$event] = $key; + } + } + } + $this->pendingEventPersist->removeAll($this->pendingEventPersist); + foreach ($this->metrics as $metric) { + if (isset($this->pendingMetricPersist[$metric]) && !isset($this->metricKeys[$metric])) { + $key = $this->persistMetricToRedis($this->redisHandler, $metric); + if ($key !== null) { + $this->metricKeys[$metric] = $key; + } + } + } + $this->pendingMetricPersist->removeAll($this->pendingMetricPersist); + } + /** Delete the given event keys from Redis after the transport confirms. */ public function confirmEventRedisKeys(array $keys): void { @@ -309,6 +345,9 @@ private function isMetricBufferFull(): bool private function forgetOldestEventKey(): ?string { $oldest = $this->events[0] ?? null; + if ($oldest !== null) { + unset($this->pendingEventPersist[$oldest]); + } if ($oldest === null || !isset($this->eventKeys[$oldest])) { return null; } @@ -321,6 +360,9 @@ private function forgetOldestEventKey(): ?string private function forgetOldestMetricKey(): ?string { $oldest = $this->metrics[0] ?? null; + if ($oldest !== null) { + unset($this->pendingMetricPersist[$oldest]); + } if ($oldest === null || !isset($this->metricKeys[$oldest])) { return null; } @@ -333,6 +375,9 @@ private function forgetOldestMetricKey(): ?string private function forgetNewestEventKey(): ?string { $newest = $this->events === [] ? null : $this->events[count($this->events) - 1]; + if ($newest !== null) { + unset($this->pendingEventPersist[$newest]); + } if ($newest === null || !isset($this->eventKeys[$newest])) { return null; } @@ -345,6 +390,9 @@ private function forgetNewestEventKey(): ?string private function forgetNewestMetricKey(): ?string { $newest = $this->metrics === [] ? null : $this->metrics[count($this->metrics) - 1]; + if ($newest !== null) { + unset($this->pendingMetricPersist[$newest]); + } if ($newest === null || !isset($this->metricKeys[$newest])) { return null; } @@ -367,15 +415,10 @@ public function addEvent(SecurityEvent $event): void { for (;;) { if (!$this->isEventBufferFull()) { - try { - $this->events[] = $event; - $this->eventsBuffered++; - $key = $this->persistEventToRedis($event); - if ($key !== null) { - $this->eventKeys[$event] = $key; - } - } catch (\Throwable $error) { - $this->logger->error('Failed to buffer event: ' . ErrorHook::message($error)); + $this->events[] = $event; + $this->eventsBuffered++; + if ($this->redisHandler !== null) { + $this->pendingEventPersist[$event] = true; } break; } @@ -415,15 +458,10 @@ public function addMetric(SecurityMetric $metric): void { for (;;) { if (!$this->isMetricBufferFull()) { - try { - $this->metrics[] = $metric; - $this->metricsBuffered++; - $key = $this->persistMetricToRedis($metric); - if ($key !== null) { - $this->metricKeys[$metric] = $key; - } - } catch (\Throwable $error) { - $this->logger->error('Failed to buffer metric: ' . ErrorHook::message($error)); + $this->metrics[] = $metric; + $this->metricsBuffered++; + if ($this->redisHandler !== null) { + $this->pendingMetricPersist[$metric] = true; } break; } @@ -473,6 +511,7 @@ public function flushEventsWithKeys(): array }, $events); $this->events = []; $this->eventKeys->removeAll($this->eventKeys); + $this->pendingEventPersist->removeAll($this->pendingEventPersist); $this->eventsFlushed += count($events); $this->lastFlushTime = microtime(true); @@ -492,6 +531,7 @@ public function flushMetricsWithKeys(): array }, $metrics); $this->metrics = []; $this->metricKeys->removeAll($this->metricKeys); + $this->pendingMetricPersist->removeAll($this->pendingMetricPersist); $this->metricsFlushed += count($metrics); $this->lastFlushTime = microtime(true); @@ -530,6 +570,8 @@ public function requeueEventsInMemory(array $events, array $keys): array array_unshift($this->events, $event); if ($key !== '') { $this->eventKeys[$event] = $key; + } elseif ($this->redisHandler !== null) { + $this->pendingEventPersist[$event] = true; } } @@ -565,6 +607,8 @@ public function requeueMetricsInMemory(array $metrics, array $keys): array array_unshift($this->metrics, $metric); if ($key !== '') { $this->metricKeys[$metric] = $key; + } elseif ($this->redisHandler !== null) { + $this->pendingMetricPersist[$metric] = true; } } @@ -579,9 +623,11 @@ public function clearBuffer(): void { $this->events = []; $this->eventKeys->removeAll($this->eventKeys); + $this->pendingEventPersist->removeAll($this->pendingEventPersist); $this->metrics = []; $this->metricKeys->removeAll($this->metricKeys); + $this->pendingMetricPersist->removeAll($this->pendingMetricPersist); if ($this->redisHandler !== null) { try { diff --git a/src/GuardAgent.php b/src/GuardAgent.php index 14a2d57..7afc6a3 100644 --- a/src/GuardAgent.php +++ b/src/GuardAgent.php @@ -316,6 +316,10 @@ public function flushIfNeeded(): void $timeElapsed = $timeSinceLastFlush >= $this->config->flushInterval; if (!$atWatermark && !$timeElapsed) { + // Long-running workers: make buffered items durable between + // flushes, off the request path (a due flush persists at its end). + $this->buffer->persistPending(); + return; } @@ -491,6 +495,9 @@ public function flushBuffer(): void $this->flushEvents(); $this->flushMetrics(); $this->flushConsecutiveFailures = 0; + // Anything still buffered (requeued, gated by backoff, or added + // since) is made durable here, after the send, off the add path. + $this->buffer->persistPending(); } catch (\Throwable $error) { // Mirror of the Python flush-loop catch: count consecutive loop // failures (the loop body itself is host-driven in this port). diff --git a/src/Transport/HttpTransport.php b/src/Transport/HttpTransport.php index 7b0a995..652cc6b 100644 --- a/src/Transport/HttpTransport.php +++ b/src/Transport/HttpTransport.php @@ -420,7 +420,10 @@ private function initEncryption(): void } catch (\Throwable $error) { $this->encryptor = null; $this->encryptionEnabled = false; - throw new EncryptionConfigException('Encryption round-trip failed at startup; refusing plaintext fallback', previous: $error); + throw new EncryptionConfigException( + 'Encryption round-trip failed at startup; refusing plaintext fallback (cause: ' . $error->getMessage() . ')', + previous: $error + ); } } From e1239d268476013c7fca843a3ae5b06c7f9d5626 Mon Sep 17 00:00:00 2001 From: Renn F Date: Thu, 15 Oct 2026 07:54:43 +0200 Subject: [PATCH 2/2] fix(buffer): persist pending events at shutdown A request that ends before any flush or tick (normal end, exit, uncaught exception, fatal error, out of memory, max_execution_time) now writes its pending items to Redis from a shutdown function registered once when persistence is attached. bin/test_shutdown.php proves each case against a real Redis and documents that SIGKILL and an unhandled SIGTERM run no PHP code. --- .github/coverage-runner.php | 10 +- .github/workflows/ci.yml | 3 + AGENTS.md | 7 +- CLAUDE.md | 9 +- Makefile | 2 +- README.md | 2 +- bin/test_agent.php | 10 ++ bin/test_shutdown.php | 170 ++++++++++++++++++++ src/.agents/skills/guard-agent-php/SKILL.md | 2 +- src/GuardAgent.php | 24 +++ 10 files changed, 225 insertions(+), 14 deletions(-) create mode 100644 bin/test_shutdown.php diff --git a/.github/coverage-runner.php b/.github/coverage-runner.php index 4a96d6b..97efdc4 100644 --- a/.github/coverage-runner.php +++ b/.github/coverage-runner.php @@ -36,13 +36,15 @@ // and degrade to a return value, so no Throwable can reach the buffer's // own catch arms. 'src/EventBuffer/EventBuffer.php' => [198, 199, 307, 308, 325, 326, 641, 642], - // stop()'s close catch: GuardAgent::$redisHandler is the final + // persistAtShutdown()'s catch: persistPending() is total, every Redis + // call inside it is caught by RedisHandler (176-177). stop()'s close + // catch: GuardAgent::$redisHandler is the final // RedisHandler (initializeRedis hard-types it) whose close() catches - // every Throwable (252-253). asMetadataArray/asStringArray fallbacks: + // every Throwable (276-277). asMetadataArray/asStringArray fallbacks: // the inputs are SecurityEvent::$metadata / SecurityMetric::$tags array // properties passed through HeadersRedactor::sanitize, which returns an - // array for an array input at the top depth (778, 789). - 'src/GuardAgent.php' => [252, 253, 785, 796], + // array for an array input at the top depth (809, 820). + 'src/GuardAgent.php' => [176, 177, 276, 277, 809, 820], // read()/write() catch arms over @-suppressed filesystem calls, which // return false instead of throwing (76-77, 79, 95-96): is_file($path) // true implies a non-empty path, and the PHP 8 ValueErrors in this diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd20f46..24d9953 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,6 +55,9 @@ jobs: REDIS_HOST: 127.0.0.1 run: php bin/test_agent.php + - name: test_shutdown + run: php bin/test_shutdown.php + composer-audit: name: Composer audit runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index d3ab232..0207005 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ All of `src/`, namespace `RenzoFranceschini\GuardAgent`: - `GuardAgent.php`: the client. `__construct(AgentConfig|array $config, ?TransportInterface $transport = null)` (the second parameter is the test seam). `start()` / `stop()` / `close()` lifecycle (start idempotent and may throw; stop idempotent and never throws); `sendEvent()` / `sendMetric()` ingest path (never throw, normalize + redact + buffer); `flushBuffer()` (never throws); `tick()` and `flushIfNeeded()` (host-driven loop, see Runtime model below); `getDynamicRules()` (TTL-cached rules fetch, never throws); `getStatus()` / `getStats()` / `healthCheck()`. Per-kind failure streaks with backoff gates (`eventsRetryAfter` / `metricsRetryAfter`), `flushConsecutiveFailures` / `statusConsecutiveFailures` / `rulesConsecutiveFailures` loop counters, `lastStatusPushOk`, `rulesFetched` / `cachedRules` / `rulesLastUpdate`. - `Config/AgentConfig.php`: readonly resolved config (camelCase, mirroring the TS port naming; defaults identical to the Python agent). `Config/AgentConfigResolver.php`: `resolve(array $input)` (accepts camelCase and snake_case keys), `validate()` (returns the error list), `normalizeEndpoint()` (strips trailing slashes and a legacy `/api/v1` suffix with a one-time warning, mirroring guard_agent/models.py:176-205). `Config/BufferOverflowPolicy.php`: `drop` / `block` / `raise` enum. `Config/RedisConfig.php`: url, keyPrefix (`guard:agent`), password, db, commandTimeoutMs (5000). -- `EventBuffer/EventBuffer.php`: per-kind bounded queues with `SplObjectStorage` key maps; overflow policies; `flushEventsWithKeys()` / `flushMetricsWithKeys()` / `requeueEventsInMemory()` / `requeueMetricsInMemory()` (front requeue, tail eviction returns the evicted keys); `confirmEventRedisKeys()` / `confirmMetricRedisKeys()`; `persistPending()` (writes unkeyed buffered items, TTL 3600; never called from add; run by `flushBuffer()` after the send and by `flushIfNeeded()` when no flush is due); `initializeRedis()` reloads persisted items at startup; `clearBuffer()`; `atHighWatermark()`. +- `EventBuffer/EventBuffer.php`: per-kind bounded queues with `SplObjectStorage` key maps; overflow policies; `flushEventsWithKeys()` / `flushMetricsWithKeys()` / `requeueEventsInMemory()` / `requeueMetricsInMemory()` (front requeue, tail eviction returns the evicted keys); `confirmEventRedisKeys()` / `confirmMetricRedisKeys()`; `persistPending()` (writes unkeyed buffered items, TTL 3600; never called from add; run by `flushBuffer()` after the send, by `flushIfNeeded()` when no flush is due, and by `GuardAgent::persistAtShutdown()` from a shutdown function registered once in `initializeRedis()`); `initializeRedis()` reloads persisted items at startup; `clearBuffer()`; `atHighWatermark()`. - `Transport/HttpTransport.php`: cURL implementation of `TransportInterface`. Retry loop (`retryAttempts + 1` tries, local `RateLimiter` 100/60, `CircuitBreaker` 5 failures/60s), response classification, gzip compression, `Signer` HMAC, 413 split-or-drop, permanent-rejection drops, `RetryAfter` parsing (default 60s, honored up to 300s), `BatchId`, response-body summaries. `Transport/TransportInterface.php`: the seam (`sendEvents`/`sendMetrics`/`sendStatus` return bool; true = accepted or intentionally dropped, false = transient, caller requeues; `fetchDynamicRules()` returns `?DynamicRules` and never throws). `getWithRetry()` implements the GET path for `GET /api/v1/rules` with the same retry/circuit-breaker/Retry-After machinery as the POST loop (mirrors `_get_with_retry`, `_transport_send.py:238-289`). `Transport/CircuitBreaker.php`, `Transport/RateLimiter.php`, `Transport/Signer.php`. - `Persistence/RedisClientInterface.php`: the client seam (ping/get/set/delete/keys/close). `Persistence/StreamRedisClient.php`: dependency-free RESP2 client over `stream_socket_client` (PING, AUTH, SELECT, GET, SET EX, DEL, KEYS; command timeout via `stream_set_timeout`). `Persistence/PredisRedisClient.php` and `Persistence/ExtRedisClient.php`: optional adapters over injected `\Predis\ClientInterface`-shaped and `\Redis` instances. `Persistence/RedisHandler.php`: namespaced facade (`{keyPrefix}:{namespace}:{short}`) with the Go port's fail-open policy: 3 consecutive write failures pause writes for 30s. - `Model/SecurityEvent.php`, `Model/SecurityMetric.php`, `Model/AgentStatus.php`: readonly wire models with `normalize()` (camelCase or snake_case input, `InvalidEventException` on bad input) and `toWire()` (snake_case, ISO-8601 UTC with milliseconds). `Model/KnownTypes.php`: the advisory event-type list and the enforced metric-type list. `Model/DynamicRules.php`: the SaaS rules surface (mirrors DynamicRules, guard_agent/models.py:250-324, snake_case wire keys with camelCase properties, `normalize()` raising `InvalidRulesException`; `ttl` drives the client cache, `blocked_cloud_providers` deduped like the Python set, `ge=1` enforced only on the `auto_ban_*` fields). `Model/WireFormat.php`: timestamp parsing (DateTimeInterface, epoch SECONDS, or parseable string) and `isoUtc()`. @@ -64,7 +64,7 @@ docker run --rm -v "$PWD":/app -w /app -e REDIS_HOST=host.docker.internal php:8. docker run --rm -v "$PWD":/app -w /app composer:2 composer validate --strict ``` -The CI-verified path (from `.github/workflows/ci.yml`): checkout, `shivammathur/setup-php` from the matrix (8.2/8.3/8.4, `mbstring` extension, coverage none), `composer install --no-interaction --no-progress`, the `php -l` sweep, then `REDIS_HOST=127.0.0.1 php bin/test_agent.php` against a `redis:7-alpine` service on port 6379, plus a `composer audit` job. +The CI-verified path (from `.github/workflows/ci.yml`): checkout, `shivammathur/setup-php` from the matrix (8.2/8.3/8.4, `mbstring` extension, coverage none), `composer install --no-interaction --no-progress`, the `php -l` sweep, then `REDIS_HOST=127.0.0.1 php bin/test_agent.php` and `php bin/test_shutdown.php` against a `redis:7-alpine` service on port 6379, plus a `composer audit` job. ## Configuration @@ -104,7 +104,7 @@ Every field except `apiKey` is optional; `AgentConfigResolver::resolve()` fills - **At-least-once handshake**: drain (keys aligned with items) -> send -> on success confirm (delete Redis records); on failure requeue at the FRONT in original order, retain Redis records, arm the per-kind gate for `min(flushInterval * 2^(streak - 1), 300)` seconds. Under pressure the requeue evicts the TAIL (newest items) and the caller confirms those evicted keys so nothing orphans. - **Response classification** (mirroring `_handle_response`): 200 with a JSON dict is evaluated (`success: false` or non-empty `errors[]` means partial failure -> whole batch requeued); 200 with an unparseable body is retried; 200 non-dict and 201 are accepted; 429 raises `RateLimitedException` with Retry-After (default 60s, honored up to 300s); 401/403 are generic retried errors; 400/404/422 are `PermanentClientException` (batch dropped, counted, never retried); 413 is `PayloadTooLargeException` (split in half recursively; a singleton that still exceeds the cap is dropped); 5xx retry with backoff; any other 4xx is logged and retried. - **Circuit breaker**: 5 consecutive non-permanent failures open it for 60s, then HALF_OPEN probes. 400/404/413/422 are exempt (batch-level rejections, not health signals); 429 and curl errors count. -- **Persistence**: persisted by `persistPending()` (end of `flushBuffer()`, or `flushIfNeeded()`/`tick()` between flushes, never on add) under a globally-unique key `{prefix}:agent_events:event__<8hex>` (or `agent_metrics`), TTL 3600s. Startup reload restores items oldest-first; corrupt or missing records are warned and skipped (the TTL reclaims them). Every Redis failure is fail-open: logged, counted in `redisPersistFailures` (`durabilityDegraded`), and after 3 consecutive write failures writes pause for 30s. +- **Persistence**: persisted by `persistPending()` (end of `flushBuffer()`, or `flushIfNeeded()`/`tick()` between flushes, or the shutdown function, never on add) under a globally-unique key `{prefix}:agent_events:event__<8hex>` (or `agent_metrics`), TTL 3600s. Startup reload restores items oldest-first; corrupt or missing records are warned and skipped (the TTL reclaims them). Every Redis failure is fail-open: logged, counted in `redisPersistFailures` (`durabilityDegraded`), and after 3 consecutive write failures writes pause for 30s. A shutdown function the agent registers once when Redis persistence is attached (`initializeRedis()`, including the one `start()` builds from `config.redis`) runs `persistAtShutdown()`, which calls `persistPending()`, so items buffered by a request are written to Redis when the process ends by normal end, `exit()`, an uncaught exception, a fatal error, out of memory or `max_execution_time` (proved by `bin/test_shutdown.php`). Events are lost only when PHP runs no code at all: SIGKILL (for example the OOM killer or `kill -9`), and SIGTERM unless the host installs its own handler (plain PHP skips shutdown functions on SIGTERM; with ext-pcntl a host handler that calls `exit()` makes it durable). The agent installs no signal handler because it would override the host's. It does not call `fastcgi_finish_request()` either: that would cut off output and shutdown functions the host registers later, so a host that wants the response out first calls it itself. - **Degraded status**: circuit OPEN, or buffer >= 90 percent occupancy, or lifetime failure rate > 10 percent. `healthCheck()` additionally fails at >= 95 percent occupancy or > 50 percent failure rate, and when not running. ## Development Commands @@ -112,6 +112,7 @@ Every field except `apiKey` is optional; `AgentConfigResolver::resolve()` fills | Command | What it runs | | --- | --- | | `composer test` | `php bin/test_agent.php` | +| `php bin/test_shutdown.php` | child-process proof of persistence at process end (needs Redis on 6379; `REDIS_HOST=0` skips) | | `composer lint` | `for f in $(find src bin -name '*.php'); do php -l "$f" > /dev/null \|\| exit 1; done && echo LINT_OK` (example files are linted with `php -l` directly) | | `composer validate --strict` | composer.json hygiene (no version field, valid schema) | | `pip install mkdocs-material && mkdocs build --strict` | Build the documentation site (CI deploys it on `main`) | diff --git a/CLAUDE.md b/CLAUDE.md index 1328761..0207005 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,7 +28,7 @@ All of `src/`, namespace `RenzoFranceschini\GuardAgent`: - `GuardAgent.php`: the client. `__construct(AgentConfig|array $config, ?TransportInterface $transport = null)` (the second parameter is the test seam). `start()` / `stop()` / `close()` lifecycle (start idempotent and may throw; stop idempotent and never throws); `sendEvent()` / `sendMetric()` ingest path (never throw, normalize + redact + buffer); `flushBuffer()` (never throws); `tick()` and `flushIfNeeded()` (host-driven loop, see Runtime model below); `getDynamicRules()` (TTL-cached rules fetch, never throws); `getStatus()` / `getStats()` / `healthCheck()`. Per-kind failure streaks with backoff gates (`eventsRetryAfter` / `metricsRetryAfter`), `flushConsecutiveFailures` / `statusConsecutiveFailures` / `rulesConsecutiveFailures` loop counters, `lastStatusPushOk`, `rulesFetched` / `cachedRules` / `rulesLastUpdate`. - `Config/AgentConfig.php`: readonly resolved config (camelCase, mirroring the TS port naming; defaults identical to the Python agent). `Config/AgentConfigResolver.php`: `resolve(array $input)` (accepts camelCase and snake_case keys), `validate()` (returns the error list), `normalizeEndpoint()` (strips trailing slashes and a legacy `/api/v1` suffix with a one-time warning, mirroring guard_agent/models.py:176-205). `Config/BufferOverflowPolicy.php`: `drop` / `block` / `raise` enum. `Config/RedisConfig.php`: url, keyPrefix (`guard:agent`), password, db, commandTimeoutMs (5000). -- `EventBuffer/EventBuffer.php`: per-kind bounded queues with `SplObjectStorage` key maps; overflow policies; `flushEventsWithKeys()` / `flushMetricsWithKeys()` / `requeueEventsInMemory()` / `requeueMetricsInMemory()` (front requeue, tail eviction returns the evicted keys); `confirmEventRedisKeys()` / `confirmMetricRedisKeys()`; persist-on-enqueue with TTL 3600; `initializeRedis()` reloads persisted items at startup; `clearBuffer()`; `atHighWatermark()`. +- `EventBuffer/EventBuffer.php`: per-kind bounded queues with `SplObjectStorage` key maps; overflow policies; `flushEventsWithKeys()` / `flushMetricsWithKeys()` / `requeueEventsInMemory()` / `requeueMetricsInMemory()` (front requeue, tail eviction returns the evicted keys); `confirmEventRedisKeys()` / `confirmMetricRedisKeys()`; `persistPending()` (writes unkeyed buffered items, TTL 3600; never called from add; run by `flushBuffer()` after the send, by `flushIfNeeded()` when no flush is due, and by `GuardAgent::persistAtShutdown()` from a shutdown function registered once in `initializeRedis()`); `initializeRedis()` reloads persisted items at startup; `clearBuffer()`; `atHighWatermark()`. - `Transport/HttpTransport.php`: cURL implementation of `TransportInterface`. Retry loop (`retryAttempts + 1` tries, local `RateLimiter` 100/60, `CircuitBreaker` 5 failures/60s), response classification, gzip compression, `Signer` HMAC, 413 split-or-drop, permanent-rejection drops, `RetryAfter` parsing (default 60s, honored up to 300s), `BatchId`, response-body summaries. `Transport/TransportInterface.php`: the seam (`sendEvents`/`sendMetrics`/`sendStatus` return bool; true = accepted or intentionally dropped, false = transient, caller requeues; `fetchDynamicRules()` returns `?DynamicRules` and never throws). `getWithRetry()` implements the GET path for `GET /api/v1/rules` with the same retry/circuit-breaker/Retry-After machinery as the POST loop (mirrors `_get_with_retry`, `_transport_send.py:238-289`). `Transport/CircuitBreaker.php`, `Transport/RateLimiter.php`, `Transport/Signer.php`. - `Persistence/RedisClientInterface.php`: the client seam (ping/get/set/delete/keys/close). `Persistence/StreamRedisClient.php`: dependency-free RESP2 client over `stream_socket_client` (PING, AUTH, SELECT, GET, SET EX, DEL, KEYS; command timeout via `stream_set_timeout`). `Persistence/PredisRedisClient.php` and `Persistence/ExtRedisClient.php`: optional adapters over injected `\Predis\ClientInterface`-shaped and `\Redis` instances. `Persistence/RedisHandler.php`: namespaced facade (`{keyPrefix}:{namespace}:{short}`) with the Go port's fail-open policy: 3 consecutive write failures pause writes for 30s. - `Model/SecurityEvent.php`, `Model/SecurityMetric.php`, `Model/AgentStatus.php`: readonly wire models with `normalize()` (camelCase or snake_case input, `InvalidEventException` on bad input) and `toWire()` (snake_case, ISO-8601 UTC with milliseconds). `Model/KnownTypes.php`: the advisory event-type list and the enforced metric-type list. `Model/DynamicRules.php`: the SaaS rules surface (mirrors DynamicRules, guard_agent/models.py:250-324, snake_case wire keys with camelCase properties, `normalize()` raising `InvalidRulesException`; `ttl` drives the client cache, `blocked_cloud_providers` deduped like the Python set, `ge=1` enforced only on the `auto_ban_*` fields). `Model/WireFormat.php`: timestamp parsing (DateTimeInterface, epoch SECONDS, or parseable string) and `isoUtc()`. @@ -64,7 +64,7 @@ docker run --rm -v "$PWD":/app -w /app -e REDIS_HOST=host.docker.internal php:8. docker run --rm -v "$PWD":/app -w /app composer:2 composer validate --strict ``` -The CI-verified path (from `.github/workflows/ci.yml`): checkout, `shivammathur/setup-php` from the matrix (8.2/8.3/8.4, `mbstring` extension, coverage none), `composer install --no-interaction --no-progress`, the `php -l` sweep, then `REDIS_HOST=127.0.0.1 php bin/test_agent.php` against a `redis:7-alpine` service on port 6379, plus a `composer audit` job. +The CI-verified path (from `.github/workflows/ci.yml`): checkout, `shivammathur/setup-php` from the matrix (8.2/8.3/8.4, `mbstring` extension, coverage none), `composer install --no-interaction --no-progress`, the `php -l` sweep, then `REDIS_HOST=127.0.0.1 php bin/test_agent.php` and `php bin/test_shutdown.php` against a `redis:7-alpine` service on port 6379, plus a `composer audit` job. ## Configuration @@ -104,7 +104,7 @@ Every field except `apiKey` is optional; `AgentConfigResolver::resolve()` fills - **At-least-once handshake**: drain (keys aligned with items) -> send -> on success confirm (delete Redis records); on failure requeue at the FRONT in original order, retain Redis records, arm the per-kind gate for `min(flushInterval * 2^(streak - 1), 300)` seconds. Under pressure the requeue evicts the TAIL (newest items) and the caller confirms those evicted keys so nothing orphans. - **Response classification** (mirroring `_handle_response`): 200 with a JSON dict is evaluated (`success: false` or non-empty `errors[]` means partial failure -> whole batch requeued); 200 with an unparseable body is retried; 200 non-dict and 201 are accepted; 429 raises `RateLimitedException` with Retry-After (default 60s, honored up to 300s); 401/403 are generic retried errors; 400/404/422 are `PermanentClientException` (batch dropped, counted, never retried); 413 is `PayloadTooLargeException` (split in half recursively; a singleton that still exceeds the cap is dropped); 5xx retry with backoff; any other 4xx is logged and retried. - **Circuit breaker**: 5 consecutive non-permanent failures open it for 60s, then HALF_OPEN probes. 400/404/413/422 are exempt (batch-level rejections, not health signals); 429 and curl errors count. -- **Persistence**: persist-on-enqueue under a globally-unique key `{prefix}:agent_events:event__<8hex>` (or `agent_metrics`), TTL 3600s. Startup reload restores items oldest-first; corrupt or missing records are warned and skipped (the TTL reclaims them). Every Redis failure is fail-open: logged, counted in `redisPersistFailures` (`durabilityDegraded`), and after 3 consecutive write failures writes pause for 30s. +- **Persistence**: persisted by `persistPending()` (end of `flushBuffer()`, or `flushIfNeeded()`/`tick()` between flushes, or the shutdown function, never on add) under a globally-unique key `{prefix}:agent_events:event__<8hex>` (or `agent_metrics`), TTL 3600s. Startup reload restores items oldest-first; corrupt or missing records are warned and skipped (the TTL reclaims them). Every Redis failure is fail-open: logged, counted in `redisPersistFailures` (`durabilityDegraded`), and after 3 consecutive write failures writes pause for 30s. A shutdown function the agent registers once when Redis persistence is attached (`initializeRedis()`, including the one `start()` builds from `config.redis`) runs `persistAtShutdown()`, which calls `persistPending()`, so items buffered by a request are written to Redis when the process ends by normal end, `exit()`, an uncaught exception, a fatal error, out of memory or `max_execution_time` (proved by `bin/test_shutdown.php`). Events are lost only when PHP runs no code at all: SIGKILL (for example the OOM killer or `kill -9`), and SIGTERM unless the host installs its own handler (plain PHP skips shutdown functions on SIGTERM; with ext-pcntl a host handler that calls `exit()` makes it durable). The agent installs no signal handler because it would override the host's. It does not call `fastcgi_finish_request()` either: that would cut off output and shutdown functions the host registers later, so a host that wants the response out first calls it itself. - **Degraded status**: circuit OPEN, or buffer >= 90 percent occupancy, or lifetime failure rate > 10 percent. `healthCheck()` additionally fails at >= 95 percent occupancy or > 50 percent failure rate, and when not running. ## Development Commands @@ -112,6 +112,7 @@ Every field except `apiKey` is optional; `AgentConfigResolver::resolve()` fills | Command | What it runs | | --- | --- | | `composer test` | `php bin/test_agent.php` | +| `php bin/test_shutdown.php` | child-process proof of persistence at process end (needs Redis on 6379; `REDIS_HOST=0` skips) | | `composer lint` | `for f in $(find src bin -name '*.php'); do php -l "$f" > /dev/null \|\| exit 1; done && echo LINT_OK` (example files are linted with `php -l` directly) | | `composer validate --strict` | composer.json hygiene (no version field, valid schema) | | `pip install mkdocs-material && mkdocs build --strict` | Build the documentation site (CI deploys it on `main`) | @@ -124,7 +125,7 @@ Docker equivalents are in Quick Start. There is no Makefile (the sibling PHP rep - `REDIS_HOST` controls the persistence leg: `REDIS_HOST=0` skips the real-Redis integration (unit-only), any hostname runs it against port 6379. The fake-Redis handshake tests always run. - The mock-server integration smoke spawns `php -S` with `tests/mock_server.php`, which mirrors the ingestion contract: size-based 413 (decompressed body > 262144 bytes), signature verification over the uncompressed body with an optional `requireSigned` mode, scripted per-path responses from a control file (status, body, Retry-After), and a JSON state log the tests assert against. - Pinned HMAC vector: `Signer::signPayload('{"hello":"world"}', 'test-secret')` must equal `v1=84cc33df716ed0b0598f07437c94069ace3730358778a592bd6bbd1423d111f3`. -- Coverage expectations when editing: config validation (all constraint branches), buffer overflow policies and requeue eviction, the handshake order (drain -> send -> confirm/requeue/gate), retry classification (200 partial, 429, 400, 401, 413 split and singleton drop, 5xx, dead endpoint), breaker open/exempt/half-open, persistence (persist-on-enqueue, TTL, reload, confirm deletes, fail-open cooldown), and the smoke contract assertions (headers, gzip, signature, endpoints). +- Coverage expectations when editing: config validation (all constraint branches), buffer overflow policies and requeue eviction, the handshake order (drain -> send -> confirm/requeue/gate), retry classification (200 partial, 429, 400, 401, 413 split and singleton drop, 5xx, dead endpoint), breaker open/exempt/half-open, persistence (deferred persistPending, TTL, reload, confirm deletes, fail-open cooldown), and the smoke contract assertions (headers, gzip, signature, endpoints). - `vendor/` is never committed; `composer.lock` is (zero-dependency lock, generated with the `composer:2` image). ## Best Practices diff --git a/Makefile b/Makefile index 1446d8e..342f4f2 100644 --- a/Makefile +++ b/Makefile @@ -27,7 +27,7 @@ test: echo "Running full suite with REDIS_HOST=$(REDIS_HOST)"; \ fi; \ if command -v php > /dev/null 2>&1; then \ - REDIS_HOST="$(REDIS_HOST)" php $(RUNNER); \ + REDIS_HOST="$(REDIS_HOST)" php $(RUNNER) && REDIS_HOST="$(REDIS_HOST)" php bin/test_shutdown.php; \ else \ echo "php not found locally, using $(PHP_IMAGE)"; \ docker run --rm -v "$$PWD":/app -w /app -e REDIS_HOST=$(REDIS_HOST) $(PHP_IMAGE) php $(RUNNER); \ diff --git a/README.md b/README.md index 3f1fb1e..5ec9de8 100644 --- a/README.md +++ b/README.md @@ -218,7 +218,7 @@ When `projectEncryptionKey` is set (a urlsafe-base64-encoded 256-bit key issued ## Persistence -With `redis` configured, every accepted item is written to Redis under a globally-unique key (`{prefix}:agent_events:event__<8hex>`, TTL 3600s) by `persistPending()`, which `flushBuffer()` runs after its send attempt (so delivered items are never written) and `flushIfNeeded()`/`tick()` run between flushes; adding an item never waits on Redis. A process that dies before the next flush or tick loses what was buffered since; on `start()` the buffer reloads whatever a previous process left behind. Every Redis failure is fail-open: logged, counted, and after 3 consecutive write failures paused for a 30s cooldown, so an unhealthy Redis cannot tax the request path. The TTL is the backstop: at worst a lost confirmation duplicates a reload, it never loses data. +With `redis` configured, every accepted item is written to Redis under a globally-unique key (`{prefix}:agent_events:event__<8hex>`, TTL 3600s) by `persistPending()`, which `flushBuffer()` runs after its send attempt (so delivered items are never written) and `flushIfNeeded()`/`tick()` run between flushes; adding an item never waits on Redis. If the process ends before the next flush or tick, a shutdown function the agent registers once when persistence is attached writes what is still pending, so a normal end, `exit()`, an uncaught exception, a fatal error, out of memory and `max_execution_time` do not lose events (`bin/test_shutdown.php` proves each against a real Redis). Events are lost only when PHP can run no code: SIGKILL (the OOM killer, `kill -9`), and SIGTERM unless the host installs its own handler (PHP skips shutdown functions on SIGTERM; with ext-pcntl a host handler that calls `exit()` makes it durable, and the agent installs none because it would override the host's). The shutdown write runs after the response is built, so a host that wants the client released first calls `fastcgi_finish_request()` itself; the agent does not, because that would cut off output and shutdown functions registered after it. On `start()` the buffer reloads whatever a previous process left behind. Every Redis failure is fail-open: logged, counted, and after 3 consecutive write failures paused for a 30s cooldown, so an unhealthy Redis cannot tax the request path. The TTL is the backstop: at worst a lost confirmation duplicates a reload, it never loses data. ## Documentation diff --git a/bin/test_agent.php b/bin/test_agent.php index d5d426e..81db6e6 100644 --- a/bin/test_agent.php +++ b/bin/test_agent.php @@ -3385,6 +3385,16 @@ static function (string $stage, Throwable $error, array $context) use (&$hookCal $buffer->persistPending(); $t->same([], $fake->storage, 'the cooldown still blocks writes after 3 consecutive failures'); +// The shutdown hook persists whatever is still pending (the process ended +// before any flush or tick); bin/test_shutdown.php proves it end to end. +$fake = new FakeRedisClient(); +$shutdownAgent = new GuardAgent(makeConfig(['installId' => 'install-shutdown']), new RecordingTransport()); +$shutdownAgent->initializeRedis(new RedisHandler($fake, $prefix, new SilentLogger())); +$shutdownAgent->sendEvent(makeEvent()); +$t->same([], $fake->storage, 'adding does not touch redis'); +$shutdownAgent->persistAtShutdown(); +$t->same(1, count($fake->storage), 'persistAtShutdown writes the pending event'); + // The startup encryption error includes the cause, still failing closed. $caught = null; try { diff --git a/bin/test_shutdown.php b/bin/test_shutdown.php new file mode 100644 index 0000000..a8270f7 --- /dev/null +++ b/bin/test_shutdown.php @@ -0,0 +1,170 @@ + 'test-api-key-123', + 'endpoint' => 'http://127.0.0.1:9', + 'installId' => 'install-shutdown', + 'redis' => ['url' => $argv[3], 'keyPrefix' => $argv[4]], + ]); + $agent->start(); + $agent->sendEvent(['event_type' => 'shutdown-proof', 'ip_address' => '203.0.113.9', 'timestamp' => time()]); + if ($agent->buffer->getBufferSize() !== 1) { + fwrite(STDERR, "child setup failed: event not buffered\n"); + exit(8); + } + + switch ($mode) { + case 'end': + return; + case 'exit': + exit(3); + case 'exception': + throw new RuntimeException('boom'); + case 'fatal': + // A compile-time E_ERROR (not an exception): no catch can intercept it. + eval('class ShutdownProofAbstract { abstract function f(); }'); + return; + case 'oom': + ini_set('memory_limit', '32M'); + $hog = []; + for (;;) { + $hog[] = str_repeat('x', 1024 * 1024); + } + case 'timeout': + ini_set('max_execution_time', '1'); + for (;;) { + hash('sha256', 'spin'); + } + case 'sigterm-handler': + // What a host that wants SIGTERM durability installs itself. + pcntl_async_signals(true); + pcntl_signal(15, static function (): never { + exit(0); + }); + // no break + case 'sigkill': + case 'sigterm': + fwrite(STDOUT, "ready\n"); + fflush(STDOUT); + sleep(30); + return; + } + exit(9); +} + +final class T +{ + public int $passed = 0; + + public int $failed = 0; + + public function ok(bool $condition, string $label): void + { + if ($condition) { + $this->passed++; + echo "ok - {$label}\n"; + } else { + $this->failed++; + echo "FAIL - {$label}\n"; + } + } +} + +$host = getenv('REDIS_HOST'); +if ($host === false || $host === '') { + $host = '127.0.0.1'; +} +if ($host === '0') { + echo "skip - REDIS_HOST=0, shutdown persistence proof skipped\n"; + exit(0); +} +$url = 'redis://' . $host . ':6379/0'; +$client = new StreamRedisClient($url, null, null, 2.0); +$client->ping(); + +$prefix = 'guard:agent:shutdowntest:' . bin2hex(random_bytes(4)); +$pattern = $prefix . ':agent_events:*'; +$t = new T(); + +/** @return array{0: string, 1: int, 2: string} exit code, keys left in redis, stderr */ +function runChild(StreamRedisClient $client, string $url, string $prefix, string $mode, ?int $signal = null): array +{ + $cmd = [PHP_BINARY, '-d', 'display_errors=stderr', __FILE__, '--child', $mode, $url, $prefix]; + $proc = proc_open($cmd, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + if ($signal !== null) { + // The agent logs to stdout too: wait for the child's own marker. + while (($line = fgets($pipes[1])) !== false && trim($line) !== 'ready') { + } + $signaled = proc_terminate($proc, $signal); + if (!$signaled) { + throw new RuntimeException('signal not delivered'); + } + } + $stderr = (string) stream_get_contents($pipes[2]); + do { + $status = proc_get_status($proc); + usleep(10000); + } while ($status['running']); + proc_close($proc); + $code = $status['signaled'] ? 'signal ' . $status['termsig'] : 'exit ' . $status['exitcode']; + + return [$code, count($client->keys($prefix . ':agent_events:*')), $stderr]; +} + +function clean(StreamRedisClient $client, string $pattern): void +{ + foreach ($client->keys($pattern) as $key) { + $client->delete($key); + } +} + +try { + foreach (['end', 'exit', 'exception', 'fatal', 'oom', 'timeout'] as $mode) { + clean($client, $pattern); + [$code, $keys, $stderr] = runChild($client, $url, $prefix, $mode); + $t->ok($keys === 1, "{$mode}: event persisted to redis at shutdown ({$code}, keys {$keys})"); + } + + foreach ([['sigkill', 9], ['sigterm', 15]] as [$mode, $signal]) { + clean($client, $pattern); + [$code, $keys] = runChild($client, $url, $prefix, $mode, $signal); + $t->ok($keys === 0, "{$mode}: no PHP code runs, event is not in redis ({$code}, keys {$keys})"); + } + if (function_exists('pcntl_signal')) { + clean($client, $pattern); + [$code, $keys] = runChild($client, $url, $prefix, 'sigterm-handler', 15); + $t->ok($keys === 1, "sigterm with a host pcntl handler that calls exit(): shutdown runs, event persisted ({$code}, keys {$keys})"); + } +} finally { + clean($client, $pattern); +} + +echo "\npassed: {$t->passed}\nfailed: {$t->failed}\n"; +exit($t->failed === 0 ? 0 : 1); diff --git a/src/.agents/skills/guard-agent-php/SKILL.md b/src/.agents/skills/guard-agent-php/SKILL.md index d7e03a2..8f4cb1c 100644 --- a/src/.agents/skills/guard-agent-php/SKILL.md +++ b/src/.agents/skills/guard-agent-php/SKILL.md @@ -11,7 +11,7 @@ description: Use when an agent works in rennf93/guard-agent-php or embeds the PH - Entry point: `new GuardAgent(AgentConfigResolver::resolve([...]))`. `start()` loads crash-recovery state; `sendEvent()` / `sendMetric()` never throw; `flushBuffer()` never throws; `tick()` is the host-driven loop body (flush + status cadence); `stop()` is idempotent and performs the final flush. - Failure isolation: host-facing methods never throw into the request path. The only opt-in exception is `bufferOverflowPolicy: 'block'`, which drains inline. - Wire contract: `POST /api/v1/events|metrics|status`, `X-API-Key` + `X-Project-Id` + `X-Agent-Install-Id`, gzip above `compressionThreshold`, `X-Payload-Signature: v1=` (the server verifies post-decompression). -- Semantics: per-kind backoff `min(flushInterval * 2^(streak - 1), 300s)`; circuit breaker 5 failures/60s with 400/404/413/422 exempt; 429 Retry-After honored to 300s; Redis persistence TTL 3600s, deferred persist (persistPending at flush/tick, never on add), startup reload, fail-open with a 30s cooldown after 3 write failures. +- Semantics: per-kind backoff `min(flushInterval * 2^(streak - 1), 300s)`; circuit breaker 5 failures/60s with 400/404/413/422 exempt; 429 Retry-After honored to 300s; Redis persistence TTL 3600s, deferred persist (persistPending at flush/tick, never on add, plus a shutdown function registered once with Redis, so normal end, exit(), uncaught exception, fatal, out of memory and max_execution_time persist; only SIGKILL and an unhandled SIGTERM lose events because PHP runs no code), startup reload, fail-open with a 30s cooldown after 3 write failures. - Commands: `composer lint` (php -l sweep over `src` and `bin`), `composer test` (`php bin/test_agent.php`). CI runs the matrix php 8.2/8.3/8.4 with `REDIS_HOST=127.0.0.1`, plus `composer audit`. ## Installation diff --git a/src/GuardAgent.php b/src/GuardAgent.php index 7afc6a3..bf56b85 100644 --- a/src/GuardAgent.php +++ b/src/GuardAgent.php @@ -84,6 +84,8 @@ final class GuardAgent private bool $ownsRedisHandler = false; + private bool $shutdownRegistered = false; + private int $inFlightFlushes = 0; public int $eventsSent = 0; @@ -151,9 +153,31 @@ public function initializeRedis(RedisHandler $redisHandler): void $this->redisHandler = $redisHandler; $this->ownsRedisHandler = false; $this->buffer->initializeRedis($redisHandler); + if (!$this->shutdownRegistered) { + $this->shutdownRegistered = true; + register_shutdown_function($this->persistAtShutdown(...)); + } $this->logger->info('Redis integration initialized'); } + /** + * Last chance to make buffered items durable when the process ends before + * a flush or tick (normal end, exit(), uncaught exception, fatal error, + * out of memory, max_execution_time). It only writes Redis records, so + * it never sends and never throws. It does NOT call fastcgi_finish_request(): + * that would cut off the output and shutdown functions the host registered + * after the agent, so hosts that want the response out first call it + * themselves. SIGKILL (and SIGTERM without a host handler) run no PHP code. + */ + public function persistAtShutdown(): void + { + try { + $this->buffer->persistPending(); + } catch (\Throwable $error) { + $this->logger->warning('shutdown persist failed: ' . ErrorHook::message($error)); + } + } + public function redisHandler(): ?RedisHandler { return $this->redisHandler;