diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e6b8c3..4bbbabe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -106,6 +106,8 @@ jobs: mutation: name: Mutation testing runs-on: ubuntu-latest + # The full run takes about 8 minutes; a hanging mutant holds a thread for the 120 s Infection timeout. + timeout-minutes: 45 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/infection.json5 b/infection.json5 index 5c31504..08348ed 100644 --- a/infection.json5 +++ b/infection.json5 @@ -6,6 +6,9 @@ "logs": { "text": ".cache/infection.log" }, + // Seconds per mutant. A mutant whose covering tests take longer is skipped, not run: the transport + // helpers are covered by the whole delivery suite, which takes longer than the default 10 s. + "timeout": 120, "minMsi": 90, "minCoveredMsi": 95, "mutators": { diff --git a/src/AgentCat.php b/src/AgentCat.php index 00492b2..3cefd4a 100644 --- a/src/AgentCat.php +++ b/src/AgentCat.php @@ -83,8 +83,9 @@ public static function publishCustomEvent(object|string $serverOrSessionId, ?str * * @param float|null $timeoutSeconds The longest wait in seconds; null waits until nothing is left to send. * - * @return bool True when nothing is left to send (every event delivered, or given up after its retries); false - * when the timeout ended first. + * @return bool True when nothing is left to send (every event delivered, or given up after its retries, and no + * exporter request left); false when the timeout ended first. AgentCat's own diagnostics are neither + * counted nor waited for: they go out on their own, between requests and when the process ends. */ public static function flush(?float $timeoutSeconds = null): bool { diff --git a/src/Client.php b/src/Client.php index 66d83cb..0fa2eb3 100644 --- a/src/Client.php +++ b/src/Client.php @@ -7,6 +7,7 @@ use AgentCat\Internal\ClientSeams; use AgentCat\Internal\Constants; use AgentCat\Internal\Delivery\EventQueue; +use AgentCat\Internal\Diagnostics\Diagnostics; use AgentCat\Internal\Engine\CallInterceptor; use AgentCat\Internal\Engine\Publisher; use AgentCat\Internal\Engine\TrackedServer; @@ -18,6 +19,7 @@ use AgentCat\Internal\Support\TestMode; use AgentCat\Internal\Support\Values; use AgentCat\Internal\Tags\TagValidator; +use AgentCat\Internal\Telemetry\TelemetryManager; use AgentCat\McpSdk\McpSdkAdapter; use Psr\Log\LoggerInterface; @@ -39,6 +41,8 @@ /** @var \Closure(): bool */ private \Closure $enabled; + private bool $telemetry; + /** * Creates a client that sends through the process-wide event queue. Call it without arguments. A * dependency-injection container that autowires it gets the same client. @@ -50,6 +54,7 @@ public function __construct(?ClientSeams $seams = null) $this->publisher = new Publisher($seams->queue ?? EventQueue::global()); $this->interceptor = new CallInterceptor($this->publisher); $this->enabled = $seams->enabled ?? TestMode::sdkEnabled(...); + $this->telemetry = $seams->telemetry ?? true; } /** @@ -82,6 +87,13 @@ public function track(object $server, ?string $projectId = null, ?Options $optio } $options ??= new Options(); $projectId ??= Env::get('AGENTCAT_PROJECT_ID') ?? ''; // D36: '' means telemetry only + if ($this->telemetry) { + // TS: the first statement of track(), before the server is checked; the first call in the process decides. + Diagnostics::init($projectId, $options->disableDiagnostics, $this->publisher->queue()); + } + if ($options->disableSignalHandlers && !$this->publisher->queue()->disableSignalHandlers() && $this->publisher->queue()->claimSignalHandlersWarning()) { + Log::write('WARN: AgentCat signal handlers are already installed; disableSignalHandlers is ignored'); + } if (!McpSdkAdapter::supports($server)) { $signals = McpSdkAdapter::fingerprint($server); Log::warning(sprintf('AgentCat SDK Compatibility: unrecognized server shape | signals %s. %s', $signals === '' ? '(none)' : $signals, McpSdkAdapter::SUPPORT_MATRIX)); @@ -106,20 +118,24 @@ public function track(object $server, ?string $projectId = null, ?Options $optio return $server; } - if ($projectId === '') { + if ($this->telemetry) { + TelemetryManager::install($this->publisher->queue(), $options->exporters); // first wins (TypeScript) + } + if ($projectId === '' && $options->exporters === null) { Log::write('Warning: No projectId provided and no exporters configured. Events will not be sent anywhere.'); - } elseif (preg_match(self::PROJECT_ID_PATTERN, $projectId) !== 1) { + } elseif ($projectId !== '' && preg_match(self::PROJECT_ID_PATTERN, $projectId) !== 1) { Log::write(sprintf('WARN: Project ID "%s" does not match proj_ followed by 27 letters or digits; the AgentCat API drops its events', $projectId)); } $state = new TrackedServer($projectId, $options, McpSdkAdapter::serverInfo($server), $this->publisher); McpSdkAdapter::install($server, $state, $this->publisher, $this->interceptor); TrackedServers::add($server, $state); Log::write(sprintf( - 'AgentCat setup complete | project %s | tracing=%s context=%s reportMissing=%s exporters=0', + 'AgentCat setup complete | project %s | tracing=%s context=%s reportMissing=%s exporters=%d', $project, $options->enableTracing ? 'true' : 'false', $options->enableToolCallContext ? 'true' : 'false', $options->enableReportMissing ? 'true' : 'false', + count($options->exporters ?? []), )); return $server; @@ -228,8 +244,9 @@ public function publishCustomEvent(object|string $serverOrSessionId, ?string $pr * * @param float|null $timeoutSeconds The longest wait in seconds; null waits until nothing is left to send. * - * @return bool True when nothing is left to send (every event delivered, or given up after its retries); false - * when the timeout ended first. + * @return bool True when nothing is left to send (every event delivered, or given up after its retries, and no + * exporter request left); false when the timeout ended first. AgentCat's own diagnostics are neither + * counted nor waited for: they go out on their own, between requests and when the process ends. */ public function flush(?float $timeoutSeconds = null): bool { diff --git a/src/Exporters/DatadogExporter.php b/src/Exporters/DatadogExporter.php new file mode 100644 index 0000000..dac3306 --- /dev/null +++ b/src/Exporters/DatadogExporter.php @@ -0,0 +1,25 @@ + new DatadogExporter(apiKey: '…', site: 'datadoghq.com', service: 'my-mcp')])`. + */ +final readonly class DatadogExporter +{ + /** + * @param string $apiKey Your Datadog API key. + * @param string $site Your Datadog site, for example `datadoghq.com` or `datadoghq.eu`. + * @param string $service The service name of the logs and metrics. + * @param string|null $env Adds an `env:` tag to the logs and metrics. Default: null (no tag). + */ + public function __construct( + public string $apiKey, + public string $site, + public string $service, + public ?string $env = null, + ) {} +} diff --git a/src/Exporters/OtlpExporter.php b/src/Exporters/OtlpExporter.php new file mode 100644 index 0000000..ea11edf --- /dev/null +++ b/src/Exporters/OtlpExporter.php @@ -0,0 +1,21 @@ + new OtlpExporter(endpoint: 'http://localhost:4318')])`. + */ +final readonly class OtlpExporter +{ + /** + * @param string $endpoint The collector base URL. `/v1/traces` is added unless it is there already. + * @param array $headers Extra request headers, for example an API key. A `Content-Type` here replaces `application/json`. Default: none. + */ + public function __construct( + public string $endpoint, + public array $headers = [], + ) {} +} diff --git a/src/Exporters/PostHogExporter.php b/src/Exporters/PostHogExporter.php new file mode 100644 index 0000000..696f58e --- /dev/null +++ b/src/Exporters/PostHogExporter.php @@ -0,0 +1,23 @@ + new PostHogExporter(apiKey: 'phc_…')])`. + */ +final readonly class PostHogExporter +{ + /** + * @param string $apiKey The PostHog project API key (`phc_…`). + * @param string|null $host The PostHog host. Default: null, which uses `https://us.i.posthog.com`. + * @param bool $enableAITracing Also sends a `$ai_span` per tool call; your tags and properties can replace any `$ai_*` value. Default: false. + */ + public function __construct( + public string $apiKey, + public ?string $host = null, + public bool $enableAITracing = false, + ) {} +} diff --git a/src/Exporters/SentryExporter.php b/src/Exporters/SentryExporter.php new file mode 100644 index 0000000..a95e83a --- /dev/null +++ b/src/Exporters/SentryExporter.php @@ -0,0 +1,25 @@ + new SentryExporter(dsn: '…')])`. + */ +final readonly class SentryExporter +{ + /** + * @param string $dsn Your project's DSN, `https://@/`. An invalid DSN logs one line and the exporter is skipped. + * @param string|null $environment Added as the `environment` tag of transactions and error events. Default: null (no tag). + * @param string|null $release Added as the `release` tag of transactions and error events. Default: null (no tag). + * @param bool $enableTracing Also sends one transaction per event. Default: false. + */ + public function __construct( + public string $dsn, + public ?string $environment = null, + public ?string $release = null, + public bool $enableTracing = false, + ) {} +} diff --git a/src/Internal/ClientSeams.php b/src/Internal/ClientSeams.php index fa2b4b5..1515014 100644 --- a/src/Internal/ClientSeams.php +++ b/src/Internal/ClientSeams.php @@ -7,7 +7,7 @@ use AgentCat\Internal\Delivery\EventQueue; /** - * What tests and `AgentCat::fake()` replace in a `Client`: its event queue and its enabled gate. The constructor is + * What tests and `AgentCat::fake()` replace in a `Client`: its event queue, its enabled gate and whether it sets up telemetry. The constructor is * private, so a dependency-injection container cannot build one, and an autowired `Client` always gets the * process-wide queue and the default gate. * @@ -19,11 +19,15 @@ private function __construct( public EventQueue $queue, public ?\Closure $enabled, + public bool $telemetry, ) {} - /** @param (\Closure(): bool)|null $enabled whether `track()` instruments; null keeps the default gate (D31, D44) */ - public static function of(EventQueue $queue, ?\Closure $enabled = null): self + /** + * @param (\Closure(): bool)|null $enabled whether `track()` instruments; null keeps the default gate (D31, D44) + * @param bool $telemetry false sets up no exporters and no diagnostics (`AgentCat::fake()`) + */ + public static function of(EventQueue $queue, ?\Closure $enabled = null, bool $telemetry = true): self { - return new self($queue, $enabled); + return new self($queue, $enabled, $telemetry); } } diff --git a/src/Internal/Constants.php b/src/Internal/Constants.php index e290444..e45c706 100644 --- a/src/Internal/Constants.php +++ b/src/Internal/Constants.php @@ -41,4 +41,7 @@ final class Constants public const string SOURCE = 'agentcat'; public const string DIAGNOSTICS_SCOPE_NAME = 'agentcat-diagnostics'; public const string DEFAULT_API_BASE_URL = 'https://api.agentcat.com'; + public const string DEFAULT_DIAGNOSTICS_ENDPOINT = 'https://otel.agentcat.com'; + /** Public by design (TS: a shared ingestion key that deters drive-by traffic; not a secret). */ + public const string DEFAULT_DIAGNOSTICS_TOKEN = 'dgk_sdk_diag_3f9a2c7e1b8d4065af2e9c1d7b6a4f80'; } diff --git a/src/Internal/Delivery/EventQueue.php b/src/Internal/Delivery/EventQueue.php index 32fe5b9..b3223d7 100644 --- a/src/Internal/Delivery/EventQueue.php +++ b/src/Internal/Delivery/EventQueue.php @@ -10,8 +10,11 @@ use AgentCat\Internal\Pipeline\QueuedEvent; use AgentCat\Internal\Support\ErrorText; use AgentCat\Internal\Transport\ApiBaseUrl; +use AgentCat\Internal\Transport\CurlHttpChannel; +use AgentCat\Internal\Transport\CurlSupport; use AgentCat\Internal\Transport\CurlTransport; use AgentCat\Internal\Transport\Delivery; +use AgentCat\Internal\Transport\HttpChannel; use AgentCat\Internal\Transport\Transport; use AgentCat\Internal\Wire\WireEncoder; use AgentCat\Internal\Wire\WireMapper; @@ -21,6 +24,10 @@ * hygiene and HTTP run in `pump()`, `flush()` or `shutdown()`. At most 5 events are in delivery at once, and * at most 10 000 wait (the oldest is dropped). No method throws. * + * The queue also drives the side channel of the exporter and diagnostics requests (`channel()`) and its companions + * (`attach()`): every pass ticks each companion (except the pass after `finish()`), then pumps the channel. `flush()` + * waits for the exporter requests inside its timeout, `shutdown()` for the background ones (diagnostics) too. + * * @internal */ final class EventQueue @@ -34,6 +41,14 @@ final class EventQueue private const string GAVE_UP = 'Failed to deliver AgentCat event after retries: '; + /** Short waits when both the events and the side channel have transfers, so neither waits on the other's sockets. */ + private const float SLICE_SECONDS = 0.01; + + /** With companions, the end of the shutdown budget is kept for `finish()`: this share of it, at most the cap. */ + private const float FINISH_RESERVE_SHARE = 0.1; + + private const float FINISH_RESERVE_MAX_SECONDS = 0.5; + private static ?self $global = null; /** @var \SplQueue events not yet in delivery */ @@ -46,15 +61,22 @@ final class EventQueue private bool $accepting = true; + private bool $signalWarningLogged = false; + /** True while `pump()` or `flush()` runs: guards against re-entrant calls from hooks and exporters. */ private bool $busy = false; /** Fixed by the first `shutdown()`: later shutdowns deliver only until then (one budget per queue). */ private ?float $shutdownDeadline = null; + /** @var list */ + private array $companions = []; + /** - * @param (\Closure(): void)|null $onEnqueue the delivery-trigger registrar, called on every `add()` + * @param (\Closure(): void)|null $onEnqueue the delivery-trigger registrar, called on every `add()` and `arm()` * @param (\Closure(Event): string)|null $encode builds the HTTP body; default `WireEncoder::encode(...)` + * @param HttpChannel $channel the side channel of the exporter and diagnostics requests + * @param SignalDrain|null $signalDrain the SIGTERM and SIGINT drain that `disableSignalHandlers()` switches off */ public function __construct( private readonly EventProcessor $processor = new EventProcessor(), @@ -63,6 +85,8 @@ public function __construct( private readonly ?\Closure $onEnqueue = null, private readonly float $shutdownTimeoutSeconds = self::SHUTDOWN_TIMEOUT_SECONDS, ?\Closure $encode = null, + private readonly HttpChannel $channel = new CurlHttpChannel(), + private readonly ?SignalDrain $signalDrain = null, ) { $this->waiting = new \SplQueue(); $this->encode = $encode ?? WireEncoder::encode(...); @@ -81,12 +105,27 @@ public static function global(): self self::$global = new self(onEnqueue: static function () use ($flusher, $signals): void { $flusher->ensureRegistered(); $signals->install(); - }); + }, signalDrain: $signals); } return self::$global; } + /** Keeps the signal handlers out of the process. False when they are installed already. */ + public function disableSignalHandlers(): bool + { + return $this->signalDrain?->disable() ?? true; + } + + /** True once per queue (one per process for the global queue): for the "ignored" warning of `disableSignalHandlers`. */ + public function claimSignalHandlersWarning(): bool + { + $first = !$this->signalWarningLogged; + $this->signalWarningLogged = true; + + return $first; + } + /** Tests only: replaces the global queue; null makes `global()` build a new one. */ public static function setGlobal(?self $queue): void { @@ -104,6 +143,55 @@ public function setExporterSink(ExporterSink $exporters): void $this->exporters = $exporters; } + public function exporterSink(): ExporterSink + { + return $this->exporters; + } + + /** The side channel: exporters and diagnostics post their requests here. */ + public function channel(): HttpChannel + { + return $this->channel; + } + + public function attach(QueueCompanion $companion): void + { + $this->companions[] = $companion; + } + + /** The delivery triggers (shutdown function, signal drain) without an event: diagnostics lines need them too. */ + public function arm(): void + { + if (!$this->onEnqueue instanceof \Closure) { + return; + } + + try { + ($this->onEnqueue)(); + } catch (\Throwable $e) { + Log::write('Failed to register AgentCat event delivery as a background task: ' . ErrorText::of($e)); + } + } + + /** Anything left to send: an event, a side request, or a companion's buffer. True when a check throws. */ + public function hasWork(): bool + { + try { + if (!$this->waiting->isEmpty() || $this->transport->inFlight() > 0 || $this->channel->pending() > 0) { + return true; + } + foreach ($this->companions as $companion) { + if ($companion->hasWork()) { + return true; + } + } + + return false; + } catch (\Throwable) { + return true; + } + } + /** Stores the event. Nothing else runs on the request path. */ public function add(QueuedEvent $event): void { @@ -117,18 +205,12 @@ public function add(QueuedEvent $event): void $this->waiting->dequeue(); } $this->waiting->enqueue($event); - if ($this->onEnqueue instanceof \Closure) { - try { - ($this->onEnqueue)(); - } catch (\Throwable $e) { - Log::write('Failed to register AgentCat event delivery as a background task: ' . ErrorText::of($e)); - } - } + $this->arm(); } /** - * Non-blocking: runs the pipeline for waiting events while fewer than 5 are in delivery, then pumps the - * transport. A re-entrant call (from a hook or an exporter) does nothing. + * Non-blocking: runs the pipeline for waiting events while fewer than 5 are in delivery, pumps the transport, + * ticks the companions and pumps the side channel. A re-entrant call (from a hook or an exporter) does nothing. * * `pump()` is non-blocking only when libcurl resolves host names asynchronously (threaded resolver or * c-ares). With a synchronous resolver, starting a transfer can block for the DNS lookup. @@ -144,25 +226,39 @@ public function pump(): void } /** - * Blocking: delivers until nothing waits or is in flight (true) or the timeout passes (false); null = no - * limit. A re-entrant call (from a hook or an exporter) returns false at once. + * Blocking: delivers the events and the side channel's exporter requests until no event waits or is in flight + * and no exporter request is pending (true), or the timeout passes (false); null = no limit. The background + * requests (diagnostics) are neither counted nor waited for: they go on in later passes and at `shutdown()`. A + * re-entrant call (from a hook or an exporter) returns false at once. */ public function flush(?float $timeoutSeconds = null): bool { - return $this->deliverUntil(fn(): float => $timeoutSeconds === null ? INF : $this->transport->now() + $this->budget($timeoutSeconds)); + return $this->deliverUntil(fn(): float => $timeoutSeconds === null ? INF : $this->transport->now() + $this->budget($timeoutSeconds), background: false); } /** - * Stops accepting events, then delivers for at most the shutdown timeout (TypeScript `destroy()`). The first call - * fixes the deadline; a later call delivers only until that same deadline, and dispatches nothing once it has - * passed. So a signal drain and the shutdown function after it, or a signal during the shutdown function's flush, - * share one budget. + * Stops accepting events, then delivers the events and the side requests, background ones included, for at most + * the shutdown timeout (TypeScript `destroy()`). The first call fixes the deadline; a later call delivers only until + * that same deadline, and dispatches nothing once it has passed. So a signal drain and the shutdown function after + * it, or a signal during the shutdown function's flush, share one budget. + * + * With companions, the end of the budget is kept for them: 10 % of the shutdown timeout, at most 0.5 s. The + * delivery stops when everything is delivered or only that reserve is left. Then each companion finishes + * (diagnostics posts its buffer, also when a hung exporter or a slow AgentCat API used the rest of the budget), and + * one more pass, to the full deadline, sends what they posted. That pass ticks no companion, so `finish()` is a + * companion's last call in a shutdown. A call whose delivery never started (a re-entrant call from a hook or an + * exporter, or a clock that cannot be read) finishes no companion. */ public function shutdown(): void { $this->accepting = false; + $deadline = fn(): float => $this->shutdownDeadline ??= $this->transport->now() + $this->budget($this->shutdownTimeoutSeconds); if (!$this->shutdownDeadlinePassed()) { - $this->deliverUntil(fn(): float => $this->shutdownDeadline ??= $this->transport->now() + $this->budget($this->shutdownTimeoutSeconds)); + if ($this->companions === []) { + $this->deliverUntil($deadline); + } else { + $this->deliverWithCompanions($deadline); + } } if (!$this->waiting->isEmpty()) { Log::write(sprintf('Shutting down with %d events still in queue', $this->waiting->count())); @@ -187,7 +283,11 @@ public function stats(): array return ['queueLength' => $this->waiting->count(), 'activeRequests' => $this->transport->inFlight()]; } - /** True when an earlier `shutdown()` fixed the deadline and that deadline has passed. */ + /** + * True when an earlier `shutdown()` fixed the deadline and that deadline has passed. + * + * @phpstan-impure it reads the clock + */ private function shutdownDeadlinePassed(): bool { try { @@ -204,11 +304,37 @@ private function budget(float $timeoutSeconds): float } /** - * The delivery loop of `flush()` and `shutdown()`. A re-entrant call returns false at once. + * The shutdown delivery with companions: up to the finish reserve, then each companion's `finish()`, then the + * last pass, to the full deadline, which ticks none of them. * - * @param \Closure(): float $deadline read once, after the re-entrancy guard is taken + * @param \Closure(): float $deadline fixes the shared deadline at its first call */ - private function deliverUntil(\Closure $deadline): bool + private function deliverWithCompanions(\Closure $deadline): void + { + $this->deliverUntil(fn(): float => $deadline() - $this->finishReserve()); + // Only after a delivery that ran: a re-entrant call finds the guard still taken by the frame it runs in, and + // an unreadable clock left the deadline unset. + if (!$this->busy && $this->shutdownDeadline !== null && !$this->shutdownDeadlinePassed()) { + $this->finishCompanions(); + $this->deliverUntil($deadline, tick: false); + } + } + + /** 10 % of the shutdown budget, at most 0.5 s; never NaN, as `budget()` is not. */ + private function finishReserve(): float + { + return min(self::FINISH_RESERVE_MAX_SECONDS, self::FINISH_RESERVE_SHARE * $this->budget($this->shutdownTimeoutSeconds)); + } + + /** + * The delivery loop of `flush()` and `shutdown()`: done when no event waits or is in flight and no side request is + * pending (the background ones count only with `$background`). A re-entrant call returns false at once. + * + * @param \Closure(): float $deadline read once, after the re-entrancy guard is taken + * @param bool $tick false after `finish()`: the passes tick no companion + * @param bool $background whether it waits for the background requests (diagnostics): `shutdown()` does + */ + private function deliverUntil(\Closure $deadline, bool $tick = true, bool $background = true): bool { if ($this->busy) { return false; @@ -218,8 +344,8 @@ private function deliverUntil(\Closure $deadline): bool try { $until = $deadline(); while (true) { - $this->pumpOnce(); - if ($this->waiting->isEmpty() && $this->transport->inFlight() === 0) { + $this->pumpOnce($tick); + if ($this->waiting->isEmpty() && $this->transport->inFlight() === 0 && $this->channel->pending($background) === 0) { return true; } if ($this->transport->now() >= $until) { @@ -227,7 +353,7 @@ private function deliverUntil(\Closure $deadline): bool } // Slots freed by the last pump are refilled at once; waiting would sleep with idle slots. if ($this->waiting->isEmpty() || $this->transport->inFlight() >= self::CONCURRENCY) { - $this->transport->waitForActivity($until); + $this->wait($until); } } } catch (\Throwable $e) { @@ -239,7 +365,27 @@ private function deliverUntil(\Closure $deadline): bool } } - private function pumpOnce(): void + /** + * Waits on the transport alone, or, while side requests are pending, a slice on each that never ends past `$until`. + * `$until` is never NaN. The time left goes through `CurlSupport::waitSeconds()`: within 0 and the slice, and 0 for + * the NaN of INF - INF (a test clock that jumped to an INF deadline). + */ + private function wait(float $until): void + { + if ($this->channel->pending() === 0) { + $this->transport->waitForActivity($until); + + return; + } + $this->transport->waitForActivity(min($until, $this->transport->now() + self::SLICE_SECONDS)); + $this->channel->waitForActivity(CurlSupport::waitSeconds($until - $this->transport->now(), self::SLICE_SECONDS)); + } + + /** + * One pass: starts waiting events while fewer than 5 are in delivery and pumps the transport, then ticks each + * companion (unless `$tick` is false) and pumps the side channel. Never throws. + */ + private function pumpOnce(bool $tick = true): void { try { while (!$this->waiting->isEmpty() && $this->transport->inFlight() < self::CONCURRENCY) { @@ -249,6 +395,32 @@ private function pumpOnce(): void } catch (\Throwable $e) { Log::write(self::GAVE_UP . ErrorText::of($e)); } + if ($tick) { + foreach ($this->companions as $companion) { + try { + $companion->tick(); + } catch (\Throwable) { + // a companion never stops the queue + } + } + } + + try { + $this->channel->pump(); // CurlHttpChannel never throws; a test double's callback might + } catch (\Throwable) { + // the side channel never stops the queue + } + } + + private function finishCompanions(): void + { + foreach ($this->companions as $companion) { + try { + $companion->finish(); // diagnostics posts the lines of the drain too (D07) + } catch (\Throwable) { + // a companion never stops the shutdown + } + } } /** TypeScript `processEvent` stages 0-5, then `sendEvent`. */ diff --git a/src/Internal/Delivery/QueueCompanion.php b/src/Internal/Delivery/QueueCompanion.php new file mode 100644 index 0000000..183989b --- /dev/null +++ b/src/Internal/Delivery/QueueCompanion.php @@ -0,0 +1,30 @@ +queue)(); - $stats = $queue->stats(); - if ($this->finishRequest instanceof \Closure && $stats['queueLength'] + $stats['activeRequests'] > 0) { + if ($this->finishRequest instanceof \Closure && $queue->hasWork()) { try { ($this->finishRequest)(); // the client gets its response before the events are sent } catch (\Throwable) { diff --git a/src/Internal/Delivery/SignalDrain.php b/src/Internal/Delivery/SignalDrain.php index bae755a..ef52a88 100644 --- a/src/Internal/Delivery/SignalDrain.php +++ b/src/Internal/Delivery/SignalDrain.php @@ -33,6 +33,8 @@ final class SignalDrain private bool $installed = false; + private bool $disabled = false; + /** * @param \Closure(): EventQueue $queue the queue to deliver, looked up at the signal * @param (\Closure(int): void)|null $raise raises the signal with the default disposition: default @@ -50,10 +52,21 @@ public function __construct( $this->functionExists = $functionExists ?? function_exists(...); } + /** Never installs after this. False when the handlers are installed already (then nothing changes). */ + public function disable(): bool + { + if ($this->installed) { + return false; + } + $this->disabled = true; + + return true; + } + /** Installs the handlers once. Does nothing outside the CLI SAPI or without ext-pcntl. */ public function install(): void { - if ($this->installed || $this->sapi !== 'cli') { + if ($this->installed || $this->disabled || $this->sapi !== 'cli') { return; } foreach (['pcntl_signal', 'pcntl_signal_get_handler', 'pcntl_async_signals'] as $function) { diff --git a/src/Internal/Diagnostics/Diagnostics.php b/src/Internal/Diagnostics/Diagnostics.php new file mode 100644 index 0000000..52361e9 --- /dev/null +++ b/src/Internal/Diagnostics/Diagnostics.php @@ -0,0 +1,200 @@ +}> */ + private array $buffer = []; + + /** The body bytes of `$buffer`. */ + private int $bufferBytes = 0; + + /** The clock reading of the oldest buffered line; null while the buffer is empty. */ + private ?float $firstAt = null; + + private bool $armed = false; + + /** + * @param list $resource + * @param list $recordAttributes + * @param \Closure(): float $clock monotonic seconds + * @param \Closure(): int $wallMillis wall-clock milliseconds + */ + private function __construct( + private readonly EventQueue $queue, + private readonly array $resource, + private readonly array $recordAttributes, + private readonly \Closure $clock, + private readonly \Closure $wallMillis, + ) {} + + /** + * TS `initDiagnostics`: the first call in the process decides; a later call changes nothing. A later + * `$disabled = true` while diagnostics run logs one line, once. Never throws. + * + * @param (\Closure(): bool)|null $inTestEnvironment default `TestMode::inTestEnvironment()` (P5) + * @param (\Closure(): float)|null $clock tests: monotonic seconds + * @param (\Closure(): int)|null $wallMillis tests: wall-clock milliseconds + */ + public static function init(string $projectId, bool $disabled, EventQueue $queue, ?\Closure $inTestEnvironment = null, ?\Closure $clock = null, ?\Closure $wallMillis = null): void + { + try { + if (self::$initialized) { + if ($disabled && self::$active instanceof self && !self::$warned) { + self::$warned = true; + Log::write(self::IGNORED); + } + + return; + } + self::$initialized = true; + $testEnvironment = $inTestEnvironment instanceof \Closure ? $inTestEnvironment() : TestMode::inTestEnvironment(); + if (!DiagnosticsSwitch::enabled($disabled, Env::get('AGENTCAT_DISABLE_DIAGNOSTICS'), Env::get('DISABLE_DIAGNOSTICS'), $testEnvironment)) { + return; + } + $diagnostics = new self( + $queue, + DiagnosticsAttributes::resource($projectId), + DiagnosticsAttributes::record(), + $clock ?? static fn(): float => hrtime(true) / 1e9, + $wallMillis ?? static fn(): int => (int) (microtime(true) * 1000), // whole milliseconds, like Date.now() + ); + self::$active = $diagnostics; + $queue->attach($diagnostics); + Log::setDiagnosticsSink($diagnostics->capture(...)); + } catch (\Throwable) { + // diagnostics setup must never throw + } + } + + public static function enabled(): bool + { + return self::$active instanceof self; + } + + /** Tests only: forgets the decision and removes the sink. */ + public static function reset(): void + { + self::$initialized = false; + self::$warned = false; + self::$active = null; + Log::setDiagnosticsSink(null); + } + + /** Posts the buffer once its oldest line is 2 s old (the TypeScript timer, checked on queue passes). */ + #[\Override] + public function tick(): void + { + $firstAt = $this->firstAt; + if ($firstAt !== null && ($this->clock)() - $firstAt >= self::BATCH_FLUSH_SECONDS) { + $this->flush(); + } + } + + /** Posts what is left; an empty buffer posts nothing (a signal drain and the shutdown function both finish). */ + #[\Override] + public function finish(): void + { + $this->flush(); + } + + #[\Override] + public function hasWork(): bool + { + return $this->buffer !== []; + } + + /** The `Log` sink: buffers one line; the first line arms the queue's shutdown delivery. Never throws. */ + private function capture(string $line): void + { + try { + $now = ($this->clock)(); + [$number, $text] = Severity::of($line); + $millis = ($this->wallMillis)(); + $bytes = strlen($line); + // The oldest go until this one fits; it is kept even when its body alone is over the budget. + while ($this->buffer !== [] && (count($this->buffer) >= self::MAX_BUFFER || $this->bufferBytes + $bytes > self::MAX_BUFFER_BYTES)) { + $oldest = array_shift($this->buffer); + $this->bufferBytes -= strlen($oldest['body']['stringValue']); + } + $this->bufferBytes += $bytes; + $this->buffer[] = [ + 'timeUnixNano' => $millis === 0 ? '0' : $millis . '000000', + 'severityNumber' => $number, + 'severityText' => $text, + 'body' => ['stringValue' => $line], + 'attributes' => $this->recordAttributes, + ]; + $this->firstAt ??= $now; + if (!$this->armed) { + $this->armed = true; // before arm(): its failure line comes back through capture() + $this->queue->arm(); + } + } catch (\Throwable) { + // capture must never throw + } + } + + /** Posts the buffer as one request on the queue's side channel; nothing when it is empty. Never throws. */ + private function flush(): void + { + if ($this->buffer === []) { + return; + } + $records = $this->buffer; + $this->buffer = []; + $this->bufferBytes = 0; + $this->firstAt = null; + + try { + $this->queue->channel()->post( + new HttpRequest(DiagnosticsEndpoint::url(), ['Content-Type' => 'application/json', 'Authorization' => 'Bearer ' . DiagnosticsEndpoint::token()], DiagnosticsPayload::encode($this->resource, $records, SdkVersion::get()), background: true, connectTimeoutSeconds: self::CONNECT_TIMEOUT_SECONDS, totalTimeoutSeconds: self::TOTAL_TIMEOUT_SECONDS), + static function (): void {}, // the answer is ignored (TS) + ); + } catch (\Throwable) { + // the records are lost, like TS + } + } +} diff --git a/src/Internal/Diagnostics/DiagnosticsAttributes.php b/src/Internal/Diagnostics/DiagnosticsAttributes.php new file mode 100644 index 0000000..d753233 --- /dev/null +++ b/src/Internal/Diagnostics/DiagnosticsAttributes.php @@ -0,0 +1,95 @@ + the resource attributes of every request + */ + public static function resource(string $projectId, ?\Closure $functionExists = null): array + { + $can = $functionExists ?? function_exists(...); + $host = $can('gethostname') ? (string) gethostname() : ''; + $uname = $can('php_uname'); + + return self::list([ + $projectId !== '' ? ['agentcat.project_id', $projectId] : ['agentcat.install_id', self::installId($host, 'file://' . dirname(__DIR__, 3))], + ['agentcat.sdk.language', 'php'], + ['agentcat.sdk.version', SdkVersion::get()], + ['agentcat.mcp_sdk.version', SdkVersion::installed('mcp/sdk')], + ['process.runtime.name', 'php'], + ['process.runtime.version', PHP_VERSION], + ['process.pid', $can('getmypid') ? (string) getmypid() : null], + ['os.type', self::osType(PHP_OS_FAMILY)], + ['os.version', $uname ? php_uname('r') : null], + ['host.arch', $uname ? self::arch(php_uname('m')) : null], + ['deployment.environment', Env::get('APP_ENV')], + ]); + } + + /** @return list on every record, so they survive flattening */ + public static function record(): array + { + return self::list([ + ['agentcat.sdk.version', SdkVersion::get()], + ['process.runtime.version', PHP_VERSION], + ['agentcat.mcp_sdk.version', SdkVersion::installed('mcp/sdk')], + ]); + } + + /** The first 16 hex digits of a SHA-256: the host name is hashed, never sent. */ + public static function installId(string $hostname, string $installPath): string + { + return substr(hash('sha256', $hostname . '|' . $installPath), 0, 16); + } + + /** Node `os.platform()` names: `PHP_OS_FAMILY` in lower case (`linux`, `darwin`), and `win32` for Windows. */ + public static function osType(string $family): string + { + return $family === 'Windows' ? 'win32' : strtolower($family); + } + + /** Node `os.arch()` names for x64 and arm64 (any case); any other machine name as is. */ + public static function arch(string $machine): string + { + return match (strtolower($machine)) { + 'x86_64', 'amd64' => 'x64', + 'aarch64', 'arm64' => 'arm64', + default => $machine, + }; + } + + /** + * @param list $pairs + * + * @return list + */ + private static function list(array $pairs): array + { + $out = []; + foreach ($pairs as [$key, $value]) { + if ($value !== null && $value !== '') { + $out[] = ['key' => $key, 'value' => ['stringValue' => $value]]; + } + } + + return $out; + } +} diff --git a/src/Internal/Diagnostics/DiagnosticsEndpoint.php b/src/Internal/Diagnostics/DiagnosticsEndpoint.php new file mode 100644 index 0000000..b855f73 --- /dev/null +++ b/src/Internal/Diagnostics/DiagnosticsEndpoint.php @@ -0,0 +1,32 @@ + $resource + * @param list> $records + * + * @throws \JsonException + * @throws \UnexpectedValueException + */ + public static function encode(array $resource, array $records, string $version): string + { + return ExportJson::encode(['resourceLogs' => [[ + 'resource' => ['attributes' => $resource], + 'scopeLogs' => [['scope' => ['name' => Constants::DIAGNOSTICS_SCOPE_NAME, 'version' => $version], 'logRecords' => $records]], + ]]]); + } +} diff --git a/src/Internal/Diagnostics/DiagnosticsSwitch.php b/src/Internal/Diagnostics/DiagnosticsSwitch.php new file mode 100644 index 0000000..bdaeec8 --- /dev/null +++ b/src/Internal/Diagnostics/DiagnosticsSwitch.php @@ -0,0 +1,85 @@ + 0xFFFF) { + $code = 0xD800 + (($code - 0x10000) >> 10); // JS charCodeAt(0) of an astral code point: its high surrogate + } + $digits[] = $code < 58 ? $code - 48 : ($code < 91 ? $code - 55 : $code - 61); + } + $bytes = array_fill(0, self::BYTES, 0); + $offset = self::BYTES; + while ($digits !== []) { + if ($offset === 0) { + return null; // RangeError: Fixed length of 20 is too small + } + $quotients = []; + $remainder = 0; + foreach ($digits as $digit) { + $accumulator = $digit + $remainder * 62; + $quotient = intdiv($accumulator, 256); + if ($accumulator % 256 !== 0 && $accumulator < 0) { + --$quotient; // Math.floor, not truncation + } + $remainder = $accumulator % 256; // JS %: the sign of the dividend, like PHP + if ($quotients !== [] || $quotient > 0) { + $quotients[] = $quotient; + } + } + $bytes[--$offset] = $remainder & 0xFF; // Buffer.from() keeps the low byte + $digits = $quotients; + } + + return Ksuid::readUint32(pack('C4', ...array_slice($bytes, 0, 4))) * 1000 + Ksuid::EPOCH_MS; + } +} diff --git a/src/Internal/Logging/FileLogger.php b/src/Internal/Logging/FileLogger.php index 3026854..2c0f776 100644 --- a/src/Internal/Logging/FileLogger.php +++ b/src/Internal/Logging/FileLogger.php @@ -4,7 +4,6 @@ namespace AgentCat\Internal\Logging; -use AgentCat\Internal\SdkVersion; use Psr\Log\AbstractLogger; /** @internal */ @@ -12,9 +11,6 @@ final class FileLogger extends AbstractLogger { private readonly ?string $path; - /** `[sdk= php= mcp=]`, resolved once (03 AC-38). */ - private readonly string $versions; - /** * @param ?\Closure(): ?string $homeLookup resolves the home directory when `$path` is null */ @@ -25,7 +21,6 @@ public function __construct(?string $path = null, ?\Closure $homeLookup = null) $path = $home === null ? null : $home . '/agentcat.log'; } $this->path = $path; - $this->versions = sprintf('[sdk=%s php=%s mcp=%s]', SdkVersion::get(), PHP_VERSION, SdkVersion::installed('mcp/sdk') ?? 'unknown'); } /** @param mixed[] $context */ @@ -35,10 +30,8 @@ public function log($level, string|\Stringable $message, array $context = []): v if ($this->path === null) { return; } - $timestamp = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d\TH:i:s.v\Z'); - $line = sprintf("[%s] %s %s\n", $timestamp, $this->versions, $message); // A telemetry log must never warn: suppress the E_WARNING for unwritable paths. - @file_put_contents($this->path, $line, FILE_APPEND | LOCK_EX); + @file_put_contents($this->path, LogLine::format((string) $message) . "\n", FILE_APPEND | LOCK_EX); } /** diff --git a/src/Internal/Logging/Log.php b/src/Internal/Logging/Log.php index 893ab87..a6f9be2 100644 --- a/src/Internal/Logging/Log.php +++ b/src/Internal/Logging/Log.php @@ -18,6 +18,9 @@ final class Log /** @var array{logger: ?LoggerInterface, chosen: bool}|null the routing `divert()` replaced; null without one */ private static ?array $beforeDiversion = null; + /** @var (\Closure(string): void)|null the diagnostics tee */ + private static ?\Closure $sink = null; + /** A non-null logger wins over every later `adopt()`; null goes back to the file fallback. */ public static function setLogger(?LoggerInterface $logger): void { @@ -60,12 +63,24 @@ public static function endDiversion(): void self::$beforeDiversion = null; } + /** + * TypeScript `setDiagnosticsSink`: one sink, replaced by a later call; null removes it. It gets every line, with + * the log-file prefix (`LogLine::format()`), before the logger does, except while a diversion is active. + * + * @param (\Closure(string): void)|null $sink + */ + public static function setDiagnosticsSink(?\Closure $sink): void + { + self::$sink = $sink; + } + /** Tests only. */ public static function reset(): void { self::$logger = null; self::$chosen = false; self::$beforeDiversion = null; + self::$sink = null; } /** Never throws. The level comes from the line prefix only. */ @@ -100,6 +115,14 @@ public static function info(string $message): void private static function emit(string $level, string $message): void { + if (self::$sink instanceof \Closure && self::$beforeDiversion === null) { // a fake sends nothing (spec conflict 9) + try { + (self::$sink)(LogLine::format($message)); + } catch (\Throwable) { + // Diagnostics must never break logging. + } + } + try { self::$logger ??= new FileLogger(); self::$logger->log($level, $message); diff --git a/src/Internal/Logging/LogLine.php b/src/Internal/Logging/LogLine.php new file mode 100644 index 0000000..3cdd6e9 --- /dev/null +++ b/src/Internal/Logging/LogLine.php @@ -0,0 +1,31 @@ + php= mcp=]`, resolved once per process (03 AC-38). */ + public static function prefix(): string + { + return self::$prefix ??= sprintf('[sdk=%s php=%s mcp=%s]', SdkVersion::get(), PHP_VERSION, SdkVersion::installed('mcp/sdk') ?? 'unknown'); + } + + /** `[] `; `$now` defaults to the current time. */ + public static function format(string $message, ?\DateTimeImmutable $now = null): string + { + $now = ($now ?? new \DateTimeImmutable())->setTimezone(new \DateTimeZone('UTC')); + + return sprintf('[%s] %s %s', $now->format('Y-m-d\TH:i:s.v\Z'), self::prefix(), $message); + } +} diff --git a/src/Internal/Support/Js.php b/src/Internal/Support/Js.php index 59da19e..3403763 100644 --- a/src/Internal/Support/Js.php +++ b/src/Internal/Support/Js.php @@ -12,7 +12,7 @@ final class Js { /** ECMAScript WhiteSpace + LineTerminator. */ - private const string WHITESPACE = '[\x{0009}-\x{000D}\x{0020}\x{00A0}\x{1680}\x{2000}-\x{200A}\x{2028}\x{2029}\x{202F}\x{205F}\x{3000}\x{FEFF}]'; + public const string WHITESPACE = '[\x{0009}-\x{000D}\x{0020}\x{00A0}\x{1680}\x{2000}-\x{200A}\x{2028}\x{2029}\x{202F}\x{205F}\x{3000}\x{FEFF}]'; /** JavaScript truthiness: false for null, false, 0, 0.0, '' and NaN; true otherwise (including [] and objects). */ public static function truthy(mixed $value): bool diff --git a/src/Internal/Support/Values.php b/src/Internal/Support/Values.php index af7bbd6..b0d7e2f 100644 --- a/src/Internal/Support/Values.php +++ b/src/Internal/Support/Values.php @@ -16,6 +16,12 @@ public static function isoDate(\DateTimeInterface $date): string return \DateTimeImmutable::createFromInterface($date)->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d\TH:i:s.v\Z'); } + /** JS `Date.getTime()`: whole milliseconds since the epoch, floored (`format('Uv')` is wrong before 1970). */ + public static function epochMillis(\DateTimeInterface $date): int + { + return (int) $date->format('U') * 1000 + (int) $date->format('v'); + } + public static function enumMarker(\UnitEnum $case): string { return '[Enum: ' . $case::class . '::' . $case->name . ']'; diff --git a/src/Internal/Telemetry/Datadog/DatadogExport.php b/src/Internal/Telemetry/Datadog/DatadogExport.php new file mode 100644 index 0000000..33c5e61 --- /dev/null +++ b/src/Internal/Telemetry/Datadog/DatadogExport.php @@ -0,0 +1,49 @@ +payload->requests($event); // outside any try in TS: a failure rejects, the manager logs it + Log::write('DatadogExporter: Metrics URL: ' . $this->payload->metricsUrl); + Log::write(sprintf('DatadogExporter: Sending %d metric series', $requests['series'])); + $this->http->post($requests['logs'], $this->outcome('logs')); + $this->http->post($requests['metrics'], $this->outcome('metrics')); + } + + /** @return \Closure(HttpResponse): void */ + private function outcome(string $kind): \Closure + { + return static function (HttpResponse $response) use ($kind): void { + if ($response->error !== null) { + Log::write(sprintf('Datadog %s network error: %s', $kind, $response->error)); + + return; + } + Log::write(sprintf('Datadog %s %s - Status: %d', $kind, $response->ok() ? 'success' : 'failed', $response->status)); + }; + } +} diff --git a/src/Internal/Telemetry/Datadog/DatadogPayload.php b/src/Internal/Telemetry/Datadog/DatadogPayload.php new file mode 100644 index 0000000..ad78516 --- /dev/null +++ b/src/Internal/Telemetry/Datadog/DatadogPayload.php @@ -0,0 +1,111 @@ +site) ?? $config->site; + $site = str_ends_with($site, '/') ? substr($site, 0, -1) : $site; + + return new self("https://http-intake.logs.{$site}/api/v2/logs", "https://api.{$site}/api/v1/series", $config); + } + + /** + * @return array{logs: HttpRequest, metrics: HttpRequest, series: int} + * + * @throws JsError a tag value that is not a string (TS `value.replace`) + */ + public function requests(Event $event): array + { + $millis = Values::epochMillis($event->timestamp); + $type = $event->eventType !== '' ? str_replace('/', '.', $event->eventType) : null; + $env = Js::truthy($this->config->env) ? 'env:' . $this->config->env : null; + $resource = Js::truthy($event->resourceName) ? 'resource:' . $event->resourceName : null; + + $present = static fn(?string $tag): bool => $tag !== null; + $tags = array_filter([$env, $type === null ? null : 'event_type:' . $type, $resource, Js::truthy($event->isError) ? 'error:true' : null], $present); + $tags[] = 'source:' . Constants::SOURCE; + foreach (Values::entries($event->tags ?? []) as $key => $value) { + if (!is_string($value)) { + throw new JsError('TypeError', 'value.replace is not a function'); + } + $tags[] = 'agentcat.' . self::tagKey((string) $key) . ':' . str_replace(',', '_', $value); + } + $mcp = new \stdClass(); + $mcp->session_id = $event->sessionId; + $mcp->event_id = $event->id; + $mcp->event_type = $event->eventType; + $optional = [ + 'resource' => $event->resourceName, 'duration_ms' => $event->duration, 'user_intent' => $event->userIntent, + 'actor_id' => $event->identifyActorGivenId, 'actor_name' => $event->identifyActorName, 'client_name' => $event->clientName, + 'client_version' => $event->clientVersion, 'server_name' => $event->serverName, 'server_version' => $event->serverVersion, + 'is_error' => $event->isError, 'error' => $event->error, 'tags' => ExportJson::map($event->tags), 'properties' => ExportJson::map($event->properties), + ]; + foreach ($optional as $key => $value) { + if ($value !== null) { // JSON.stringify leaves undefined out (spec conflict 4) + $mcp->{$key} = $value; + } + } + $log = [ + 'message' => sprintf('%s - %s', $event->eventType !== '' ? $event->eventType : 'unknown', Js::truthy($event->resourceName) ? $event->resourceName : 'unknown'), + 'service' => $this->config->service, + 'ddsource' => Constants::SOURCE, + 'ddtags' => implode(',', $tags), + 'timestamp' => $millis, + 'status' => Js::truthy($event->isError) ? 'error' : 'info', + 'dd' => ['trace_id' => TraceContext::datadogTraceId($event->sessionId), 'span_id' => TraceContext::datadogSpanId($event->id)], + 'mcp' => $mcp, + ]; + if (Js::truthy($event->isError) && Js::truthy($event->error)) { + $log['error'] = ['message' => is_string($event->error) ? $event->error : ExportJson::encode($event->error)]; + } + + $seconds = intdiv($millis, 1000) - ($millis % 1000 < 0 ? 1 : 0); // Math.floor + $metricTags = array_values(array_filter(['service:' . $this->config->service, $env, $type === null ? null : 'event_type:' . $type, $resource], $present)); + $series = [['metric' => 'mcp.events.count', 'type' => 'count', 'points' => [[$seconds, 1]], 'tags' => $metricTags]]; + if (Js::truthy($event->duration)) { + $series[] = ['metric' => 'mcp.event.duration', 'type' => 'gauge', 'points' => [[$seconds, $event->duration]], 'tags' => $metricTags]; + } + if (Js::truthy($event->isError)) { + $series[] = ['metric' => 'mcp.errors.count', 'type' => 'count', 'points' => [[$seconds, 1]], 'tags' => $metricTags]; + } + $headers = ['DD-API-KEY' => $this->config->apiKey, 'Content-Type' => 'application/json']; + + return [ + 'logs' => new HttpRequest($this->logsUrl, $headers, ExportJson::encode([$log])), + 'metrics' => new HttpRequest($this->metricsUrl, $headers, ExportJson::encode(['series' => $series])), + 'series' => count($series), + ]; + } + + /** TS `key.toLowerCase().replace(/[\s:,]+/g, "_")`; invalid UTF-8 bytes become `?`. */ + public static function tagKey(string $key): string + { + return preg_replace('/(?:' . Js::WHITESPACE . '|[:,])+/u', '_', mb_strtolower($key, 'UTF-8')) ?? $key; + } +} diff --git a/src/Internal/Telemetry/ExportErrors.php b/src/Internal/Telemetry/ExportErrors.php new file mode 100644 index 0000000..87f15a7 --- /dev/null +++ b/src/Internal/Telemetry/ExportErrors.php @@ -0,0 +1,27 @@ +name . ': ' . $error->getMessage() : ErrorText::of($error); + } + + /** JS `error.message`. */ + public static function message(\Throwable $error): string + { + return $error instanceof JsError ? $error->getMessage() : ErrorText::message($error); + } +} diff --git a/src/Internal/Telemetry/ExportJson.php b/src/Internal/Telemetry/ExportJson.php new file mode 100644 index 0000000..2b81a83 --- /dev/null +++ b/src/Internal/Telemetry/ExportJson.php @@ -0,0 +1,42 @@ +: `. An exporter may call it now or from a channel callback; a + * throwable counts as a rejection with its message. + * + * @param \Closure(string): void $reject + */ + public function export(Event $event, \Closure $reject): void; +} diff --git a/src/Internal/Telemetry/ExporterConfigs.php b/src/Internal/Telemetry/ExporterConfigs.php new file mode 100644 index 0000000..8673d0e --- /dev/null +++ b/src/Internal/Telemetry/ExporterConfigs.php @@ -0,0 +1,127 @@ + …]` array becomes its config object. A bad entry logs + * one line and is skipped; the others are kept. Never throws. + * + * @internal + */ +final class ExporterConfigs +{ + private const array FIELDS = [ + 'otlp' => ['endpoint', 'headers'], + 'datadog' => ['apiKey', 'site', 'service', 'env'], + 'sentry' => ['dsn', 'environment', 'release', 'enableTracing'], + 'posthog' => ['apiKey', 'host', 'enableAITracing'], + ]; + + /** + * @param array $entries + * + * @return array + */ + public static function fromArray(array $entries): array + { + $configs = []; + foreach ($entries as $key => $entry) { + $name = (string) $key; + try { + $config = match (true) { + $entry instanceof OtlpExporter, $entry instanceof DatadogExporter, $entry instanceof SentryExporter, $entry instanceof PostHogExporter => $entry, + is_array($entry) => self::one($name, $entry), + default => throw new \InvalidArgumentException('exporter configuration must be an array, got ' . get_debug_type($entry)), + }; + } catch (\InvalidArgumentException $e) { + Log::write(sprintf('Failed to initialize exporter %s: %s', $name, ErrorText::of($e))); + + continue; + } + if ($config !== null) { + $configs[$key] = $config; + } + } + + return $configs; + } + + /** @param array $entry */ + private static function one(string $name, array $entry): OtlpExporter|DatadogExporter|SentryExporter|PostHogExporter|null + { + $type = $entry['type'] ?? null; + $fields = is_string($type) ? (self::FIELDS[$type] ?? null) : null; + if ($fields === null) { + Log::write('Unknown exporter type: ' . (array_key_exists('type', $entry) ? Js::string($type) : 'undefined')); + + return null; + } + foreach (array_keys($entry) as $key) { + if ($key !== 'type' && !in_array((string) $key, $fields, true)) { + Log::write(sprintf('WARN: Unknown AgentCat option "exporters.%s.%s" ignored', $name, $key)); + } + } + + return match ($type) { + 'otlp' => new OtlpExporter(self::string($entry, 'endpoint'), self::headers($entry)), + 'datadog' => new DatadogExporter(self::string($entry, 'apiKey'), self::string($entry, 'site'), self::string($entry, 'service'), self::optional($entry, 'env')), + 'sentry' => new SentryExporter(self::string($entry, 'dsn'), self::optional($entry, 'environment'), self::optional($entry, 'release'), self::bool($entry, 'enableTracing')), + default => new PostHogExporter(self::string($entry, 'apiKey'), self::optional($entry, 'host'), self::bool($entry, 'enableAITracing')), + }; + } + + /** @param array $entry */ + private static function string(array $entry, string $key): string + { + $value = $entry[$key] ?? null; + + return is_string($value) ? $value : throw self::invalid($key, 'string', $value); + } + + /** @param array $entry */ + private static function optional(array $entry, string $key): ?string + { + $value = $entry[$key] ?? null; + + return $value === null || is_string($value) ? $value : throw self::invalid($key, 'string or null', $value); + } + + /** @param array $entry */ + private static function bool(array $entry, string $key): bool + { + $value = $entry[$key] ?? false; + + return is_bool($value) ? $value : throw self::invalid($key, 'bool', $value); + } + + /** + * @param array $entry + * + * @return array + */ + private static function headers(array $entry): array + { + $value = $entry['headers'] ?? []; + $headers = []; + foreach (is_array($value) ? $value : throw self::invalid('headers', 'array', $value) as $header => $text) { + $headers[(string) $header] = is_string($text) ? $text : throw self::invalid('headers', 'array', $value); + } + + return $headers; + } + + private static function invalid(string $key, string $expected, mixed $value): \InvalidArgumentException + { + return new \InvalidArgumentException(sprintf('exporter option "%s" must be %s, got %s', $key, $expected, get_debug_type($value))); + } +} diff --git a/src/Internal/Telemetry/ExporterFactory.php b/src/Internal/Telemetry/ExporterFactory.php new file mode 100644 index 0000000..dbf86fa --- /dev/null +++ b/src/Internal/Telemetry/ExporterFactory.php @@ -0,0 +1,52 @@ +` line, for anything that is not one of the four + * configuration classes (`Options::$exporters` entries are not validated, Task 4). + * + * @throws \InvalidArgumentException an invalid Sentry DSN + */ + public static function create(mixed $config, HttpChannel $http): ?Exporter + { + return match (true) { + $config instanceof OtlpExporter => new OtlpExport(OtlpPayload::of($config), $http), + $config instanceof DatadogExporter => new DatadogExport(DatadogPayload::of($config), $http), + $config instanceof SentryExporter => new SentryExport(SentryPayload::of($config), $http), + $config instanceof PostHogExporter => new PostHogExport(PostHogPayload::of($config), $http), + default => self::unknown($config), + }; + } + + private static function unknown(mixed $config): null + { + Log::write('Unknown exporter type: ' . get_debug_type($config)); + + return null; + } +} diff --git a/src/Internal/Telemetry/JsError.php b/src/Internal/Telemetry/JsError.php new file mode 100644 index 0000000..fe3c29f --- /dev/null +++ b/src/Internal/Telemetry/JsError.php @@ -0,0 +1,20 @@ +`. + * + * @internal + */ +final readonly class OtlpExport implements Exporter +{ + public function __construct( + private OtlpPayload $payload, + private HttpChannel $http, + ) {} + + #[\Override] + public function export(Event $event, \Closure $reject): void + { + try { + $request = $this->payload->request($event); + } catch (\Throwable $e) { + $reject('OTLP export error: ' . ExportErrors::text($e)); + + return; + } + $id = $event->id; + $this->http->post($request, static function (HttpResponse $response) use ($reject, $id): void { + if ($response->error !== null) { + $reject('OTLP export error: ' . $response->error); + } elseif (!$response->ok()) { + $reject(sprintf('OTLP export error: Error: OTLP export failed: %d %s', $response->status, $response->reason)); + } else { + Log::write('Successfully exported event to OTLP: ' . $id); + } + }); + } +} diff --git a/src/Internal/Telemetry/Otlp/OtlpPayload.php b/src/Internal/Telemetry/Otlp/OtlpPayload.php new file mode 100644 index 0000000..261ead1 --- /dev/null +++ b/src/Internal/Telemetry/Otlp/OtlpPayload.php @@ -0,0 +1,118 @@ + $headers */ + private function __construct( + public string $url, + public array $headers, + ) {} + + public static function of(OtlpExporter $config): self + { + $url = rtrim($config->endpoint, '/'); + $headers = ['Content-Type' => 'application/json']; + foreach ($config->headers as $name => $value) { + $headers[$name] = $value; // TS spread: a user Content-Type replaces ours + } + + return new self(str_ends_with($url, '/v1/traces') ? $url : $url . '/v1/traces', $headers); + } + + /** @throws JsError a truthy duration that is not an integer (B04, D15) */ + public function request(Event $event): HttpRequest + { + $startMillis = Values::epochMillis($event->timestamp); + $attributes = []; + $add = static function (string $key, mixed $value) use (&$attributes): void { + if (Js::truthy($value)) { // TS filters attributes whose stringValue is falsy + $attributes[] = ['key' => $key, 'value' => ['stringValue' => $value]]; + } + }; + $add('source', Constants::SOURCE); + $add('mcp.event_type', $event->eventType); + $add('mcp.session_id', $event->sessionId); + $add('mcp.project_id', $event->projectId); + $add('mcp.resource_name', $event->resourceName); + $add('mcp.user_intent', $event->userIntent); + $add('mcp.actor_id', $event->identifyActorGivenId); + $add('mcp.actor_name', $event->identifyActorName); + $add('mcp.client_name', $event->clientName); + $add('mcp.client_version', $event->clientVersion); + foreach (Values::entries($event->tags ?? []) as $key => $value) { + $add('agentcat.tag.' . $key, $value); + } + if (Js::truthy($event->properties)) { + $add('agentcat.properties', ExportJson::encode(ExportJson::map($event->properties))); + } + $span = [ + 'traceId' => TraceContext::traceId($event->sessionId), + 'spanId' => TraceContext::spanId($event->id), + 'name' => $event->eventType !== '' ? $event->eventType : 'mcp.event', + 'kind' => 2, // SPAN_KIND_SERVER + 'startTimeUnixNano' => self::nanos($startMillis), + 'endTimeUnixNano' => self::endNanos($startMillis, $event->duration), + 'attributes' => $attributes, + 'status' => ['code' => Js::truthy($event->isError) ? 2 : 1], + ]; + $body = ['resourceSpans' => [[ + 'resource' => ['attributes' => [ + ['key' => 'service.name', 'value' => ['stringValue' => Js::truthy($event->serverName) ? $event->serverName : 'mcp-server']], + ['key' => 'service.version', 'value' => ['stringValue' => Js::truthy($event->serverVersion) ? $event->serverVersion : 'unknown']], + ]], + 'scopeSpans' => [['scope' => ['name' => Constants::SOURCE, 'version' => Js::truthy($event->agentcatVersion) ? $event->agentcatVersion : 'unknown'], 'spans' => [$span]]], + ]]]; + + return new HttpRequest($this->url, $this->headers, ExportJson::encode($body)); + } + + /** + * TS `start + BigInt(duration) * 1e6` when the duration is truthy. Exact in integers while the end fits an int; + * beyond that float arithmetic (spec conflict 7). + * + * @throws JsError a truthy duration that is not an integer + */ + public static function endNanos(int $startMillis, int|float|null $duration): string + { + if ($duration === null || !Js::truthy($duration)) { + return self::nanos($startMillis); + } + if (is_float($duration) && fmod($duration, 1.0) !== 0.0) { // also INF, whose fmod is NaN + throw new JsError('RangeError', sprintf('The number %s cannot be converted to a BigInt because it is not an integer', Js::string($duration))); + } + $end = abs($duration) >= self::INT_LIMIT ? $startMillis + $duration : $startMillis + (int) $duration; + if (is_int($end)) { + return self::nanos($end); + } + $text = sprintf('%.0f', $end); + + return $text === '0' ? '0' : $text . '000000'; + } + + private static function nanos(int $millis): string + { + return $millis === 0 ? '0' : $millis . '000000'; + } +} diff --git a/src/Internal/Telemetry/PostHog/PostHogExport.php b/src/Internal/Telemetry/PostHog/PostHogExport.php new file mode 100644 index 0000000..c976c8f --- /dev/null +++ b/src/Internal/Telemetry/PostHog/PostHogExport.php @@ -0,0 +1,49 @@ +` or the + * status and body. Never rejects. + * + * @internal + */ +final readonly class PostHogExport implements Exporter +{ + public function __construct( + private PostHogPayload $payload, + private HttpChannel $http, + ) { + Log::write('PostHogExporter: Initialized with endpoint ' . $payload->batchUrl); + } + + #[\Override] + public function export(Event $event, \Closure $reject): void + { + try { + $batch = $this->payload->batch($event); + Log::write(sprintf('PostHogExporter: Sending %d event(s) for %s', count($batch), $event->id)); + $id = $event->id; + $this->http->post($this->payload->request($batch), static function (HttpResponse $response) use ($id): void { + if ($response->error !== null) { + Log::write('PostHog export error: ' . $response->error); + } elseif (!$response->ok()) { + Log::write(sprintf('PostHog export failed - Status: %d, Body: %s', $response->status, $response->body)); + } else { + Log::write('PostHog export success - Event: ' . $id); + } + }); + } catch (\Throwable $e) { + Log::write('PostHog export error: ' . ExportErrors::text($e)); + } + } +} diff --git a/src/Internal/Telemetry/PostHog/PostHogPayload.php b/src/Internal/Telemetry/PostHog/PostHogPayload.php new file mode 100644 index 0000000..afca545 --- /dev/null +++ b/src/Internal/Telemetry/PostHog/PostHogPayload.php @@ -0,0 +1,183 @@ + 'mcp_tool_call', 'mcp:tools/list' => 'mcp_tools_list', 'mcp:initialize' => 'mcp_initialize', + 'mcp:resources/read' => 'mcp_resource_read', 'mcp:resources/list' => 'mcp_resources_list', + 'mcp:prompts/get' => 'mcp_prompt_get', 'mcp:prompts/list' => 'mcp_prompts_list', + ]; + + private function __construct( + public string $batchUrl, + private PostHogExporter $config, + ) {} + + public static function of(PostHogExporter $config): self + { + $host = $config->host !== null && $config->host !== '' ? $config->host : 'https://us.i.posthog.com'; + + return new self((str_ends_with($host, '/') ? substr($host, 0, -1) : $host) . '/batch', $config); + } + + /** @return list<\stdClass> */ + public function batch(Event $event): array + { + $toolCall = $event->eventType === Constants::TOOLS_CALL_EVENT_TYPE; + $capture = new \stdClass(); + $capture->source = Constants::SOURCE; + if ($event->sessionId !== '') { + $capture->{'$session_id'} = UuidV7::fromPrefixedId($event->sessionId); + } + $this->resource($capture, $event, $toolCall); + if ($event->duration !== null) { + $capture->duration_ms = $event->duration; + } + foreach (['server_name' => $event->serverName, 'server_version' => $event->serverVersion, 'client_name' => $event->clientName, 'client_version' => $event->clientVersion, 'project_id' => $event->projectId, 'user_intent' => $event->userIntent] as $key => $value) { + if (Js::truthy($value)) { + $capture->{$key} = $value; + } + } + if ($event->isError !== null) { + $capture->is_error = $event->isError; + } + if ($event->parameters !== null) { + $capture->parameters = $event->parameters; + } + if ($event->response !== null) { + $capture->response = $event->response; + } + $set = new \stdClass(); + if (Js::truthy($event->identifyActorName)) { + $set->name = $event->identifyActorName; + } + $this->spread($set, $event->identifyActorData); + if (get_object_vars($set) !== []) { + $capture->{'$set'} = $set; + } + $this->spread($capture, $event->tags); + $this->spread($capture, $event->properties); + $batch = [$this->item($event, self::eventName($event->eventType), $capture)]; + + if (Js::truthy($event->isError) && Js::truthy($event->error)) { + $exception = new \stdClass(); + $exception->{'$exception_source'} = 'backend'; + if ($event->sessionId !== '') { + $exception->{'$session_id'} = UuidV7::fromPrefixedId($event->sessionId); + } + if (Values::isObjectLike($event->error)) { + foreach (['message' => '$exception_message', 'type' => '$exception_type', 'stack' => '$exception_stacktrace'] as $key => $property) { + $value = Values::get($event->error, $key); + if (Js::truthy($value)) { + $exception->{$property} = $value; + } + } + } + $this->resource($exception, $event, $toolCall); + foreach (['server_name' => $event->serverName, 'server_version' => $event->serverVersion, 'client_name' => $event->clientName, 'client_version' => $event->clientVersion] as $key => $value) { + if (Js::truthy($value)) { + $exception->{$key} = $value; + } + } + $batch[] = $this->item($event, '$exception', $exception); + } + + if ($this->config->enableAITracing && $toolCall) { + $span = new \stdClass(); + $span->{'$ai_trace_id'} = UuidV7::fromPrefixedId($event->sessionId !== '' ? $event->sessionId : $event->id); + $span->{'$ai_span_id'} = UuidV7::fromPrefixedId($event->id); + $span->{'$ai_span_name'} = Js::truthy($event->resourceName) ? $event->resourceName : 'unknown_tool'; + $span->{'$ai_is_error'} = $event->isError === true; + $span->source = Constants::SOURCE; + if ($event->sessionId !== '') { + $span->{'$ai_session_id'} = 'agentcat_' . $event->sessionId; + $span->{'$session_id'} = UuidV7::fromPrefixedId($event->sessionId); + } + if ($event->duration !== null) { + $span->{'$ai_latency'} = $event->duration / 1000; + } + if (Js::truthy($event->isError) && Js::truthy($event->error)) { + $span->{'$ai_error'} = $event->error; + } + if ($event->parameters !== null) { + $span->{'$ai_input_state'} = $event->parameters; + } + if ($event->response !== null) { + $span->{'$ai_output_state'} = $event->response; + } + foreach (['server_name' => $event->serverName, 'client_name' => $event->clientName] as $key => $value) { + if (Js::truthy($value)) { + $span->{$key} = $value; + } + } + $this->spread($span, $event->tags); + $this->spread($span, $event->properties); + $batch[] = $this->item($event, '$ai_span', $span); + } + + return $batch; + } + + /** @param list<\stdClass> $batch */ + public function request(array $batch): HttpRequest + { + return new HttpRequest($this->batchUrl, ['Content-Type' => 'application/json'], ExportJson::encode(['api_key' => $this->config->apiKey, 'batch' => $batch])); + } + + public static function eventName(string $eventType): string + { + return self::NAMES[$eventType] ?? 'mcp_' . str_replace('/', '_', preg_replace('/\Amcp:/', '', $eventType) ?? $eventType); + } + + private function item(Event $event, string $name, \stdClass $properties): \stdClass + { + $item = new \stdClass(); + $item->event = $name; + $item->distinct_id = Js::truthy($event->identifyActorGivenId) ? $event->identifyActorGivenId : ($event->sessionId !== '' ? $event->sessionId : 'anonymous'); + $item->properties = $properties; + $item->timestamp = Values::isoDate($event->timestamp); + $item->type = 'capture'; + + return $item; + } + + private function resource(\stdClass $properties, Event $event, bool $toolCall): void + { + if (Js::truthy($event->resourceName)) { + $properties->resource_name = $event->resourceName; + if ($toolCall) { + $properties->tool_name = $event->resourceName; + } + } + } + + /** TS `for (const [k, v] of Object.entries(map)) target[k] = v` for a truthy map. */ + private function spread(\stdClass $target, mixed $map): void + { + if (!Js::truthy($map) || !Values::isObjectLike($map)) { + return; + } + foreach (Values::entries($map) as $key => $value) { + $target->{$key} = $value; + } + } +} diff --git a/src/Internal/Telemetry/Sentry/SentryDsn.php b/src/Internal/Telemetry/Sentry/SentryDsn.php new file mode 100644 index 0000000..ab82850 --- /dev/null +++ b/src/Internal/Telemetry/Sentry/SentryDsn.php @@ -0,0 +1,53 @@ +`, with the DSN masked (`masked()`) */ + public static function parse(string $dsn): self + { + if (preg_match(self::PATTERN, $dsn, $match) !== 1) { + throw new \InvalidArgumentException('Invalid Sentry DSN: ' . self::masked($dsn)); + } + + // PCRE gives '' for an optional group that did not take part, which is what TypeScript's `|| ""` produces. + return new self(sprintf('%s://%s%s%s/api/%s/envelope/', $match[1], $match[3], $match[4], $match[5], $match[6]), $match[2]); + } + + /** + * `$dsn` with its user-info as `***` (`https://***@host/1`): the public key, and the secret of a legacy + * `https://public:secret@host/1` DSN, never reach a log line or diagnostics (TypeScript prints the whole DSN). The + * user-info runs from the scheme's `://` (or the start) to the last `@`, so an `@` in a secret hides nothing; + * scheme, host, port and path stay. Without an `@`, or with nothing before it, the DSN is returned as is. + */ + private static function masked(string $dsn): string + { + $at = strrpos($dsn, '@'); + if ($at === false) { + return $dsn; + } + $scheme = strpos(substr($dsn, 0, $at), '://'); + $start = $scheme === false ? 0 : $scheme + 3; + if ($start === $at) { + return $dsn; // empty user-info: nothing to hide + } + + return substr($dsn, 0, $start) . '***' . substr($dsn, $at); + } +} diff --git a/src/Internal/Telemetry/Sentry/SentryExport.php b/src/Internal/Telemetry/Sentry/SentryExport.php new file mode 100644 index 0000000..f790a63 --- /dev/null +++ b/src/Internal/Telemetry/Sentry/SentryExport.php @@ -0,0 +1,99 @@ +` (TS: one `catch` around the awaited steps); a non-2xx answer + * logs its status and body and the sequence goes on. Never rejects. + * + * @internal + */ +final readonly class SentryExport implements Exporter +{ + public function __construct( + private SentryPayload $payload, + private HttpChannel $http, + ) { + Log::write('SentryExporter: Initialized with endpoint ' . $payload->endpoint); + } + + #[\Override] + public function export(Event $event, \Closure $reject): void + { + self::guarded(function () use ($event): void { + $envelope = $this->payload->logEnvelope($event, $this->now()); + Log::write(sprintf('SentryExporter: Sending log for event %s to Sentry', $event->id)); + $this->send($envelope, 'log', $event->id, fn() => $this->transaction($event)); + }); + } + + private function transaction(Event $event): void + { + if (!$this->payload->enableTracing) { + $this->error($event); + + return; + } + $transaction = $this->payload->transaction($event); + $envelope = $this->payload->transactionEnvelope($transaction, $this->now()); + Log::write(sprintf('SentryExporter: Sending transaction %s to Sentry', $transaction->eventId)); + $this->send($envelope, 'transaction', $event->id, fn() => $this->error($event)); + } + + private function error(Event $event): void + { + if ($event->isError !== true) { + return; + } + $error = $this->payload->errorEvent($event, $this->payload->enableTracing ? $this->payload->transaction($event) : null); + $envelope = $this->payload->errorEnvelope($error, $this->now()); + Log::write(sprintf('SentryExporter: Sending error event %s to Sentry for Issue creation', $error['eventId'])); + $this->send($envelope, 'error', $event->id, null); + } + + /** @param (\Closure(): void)|null $next the following step, after any answer */ + private function send(string $envelope, string $kind, string $id, ?\Closure $next): void + { + $this->http->post(new HttpRequest($this->payload->endpoint, $this->payload->headers(), $envelope), static function (HttpResponse $response) use ($kind, $id, $next): void { + if ($response->error !== null) { + Log::write('Sentry export error: ' . $response->error); // TS: the awaited fetch() rejects into the one catch + + return; + } + Log::write($response->ok() + ? sprintf('Sentry %s export success - Event: %s', $kind, $id) + : sprintf('Sentry %s export failed - Status: %d, Body: %s', $kind, $response->status, $response->body)); + if ($next instanceof \Closure) { + self::guarded($next); + } + }); + } + + /** @param \Closure(): void $step */ + private static function guarded(\Closure $step): void + { + try { + $step(); + } catch (\Throwable $e) { + Log::write('Sentry export error: ' . ExportErrors::text($e)); + } + } + + private function now(): string + { + return Values::isoDate(new \DateTimeImmutable('now', new \DateTimeZone('UTC'))); + } +} diff --git a/src/Internal/Telemetry/Sentry/SentryPayload.php b/src/Internal/Telemetry/Sentry/SentryPayload.php new file mode 100644 index 0000000..186833a --- /dev/null +++ b/src/Internal/Telemetry/Sentry/SentryPayload.php @@ -0,0 +1,224 @@ +dsn); + + return new self($dsn->endpoint, $config->enableTracing, 'Sentry sentry_version=7, sentry_client=agentcat/1.0.0, sentry_key=' . $dsn->publicKey, $config); + } + + /** @return array */ + public function headers(): array + { + return ['X-Sentry-Auth' => $this->auth, 'Content-Type' => 'application/x-sentry-envelope']; + } + + public function logEnvelope(Event $event, string $sentAt): string + { + $eventId = TraceContext::spanId($event->id) . TraceContext::spanId($event->id); + $attributes = new \stdClass(); + $strings = [ + 'eventType' => $event->eventType, 'resourceName' => $event->resourceName, 'serverName' => $event->serverName, + 'clientName' => $event->clientName, 'sessionId' => $event->sessionId, 'projectId' => $event->projectId, + ]; + foreach ($strings as $key => $value) { + if (Js::truthy($value)) { + $attributes->{$key} = ['value' => $value, 'type' => 'string']; + } + } + if ($event->duration !== null) { + $attributes->duration_ms = ['value' => $event->duration, 'type' => 'double']; + } + foreach (['actorId' => $event->identifyActorGivenId, 'actorName' => $event->identifyActorName, 'userIntent' => $event->userIntent, 'serverVersion' => $event->serverVersion, 'clientVersion' => $event->clientVersion] as $key => $value) { + if (Js::truthy($value)) { + $attributes->{$key} = ['value' => $value, 'type' => 'string']; + } + } + if ($event->isError !== null) { + $attributes->isError = ['value' => $event->isError, 'type' => 'boolean']; + } + $type = $event->eventType !== '' ? $event->eventType : 'event'; + $log = [ + 'timestamp' => $this->seconds($event), + 'trace_id' => TraceContext::traceId($event->sessionId), + 'event_id' => $eventId, + 'level' => Js::truthy($event->isError) ? 'error' : 'info', + 'body' => Js::truthy($event->resourceName) ? "MCP {$type}: {$event->resourceName}" : "MCP {$type}", + 'attributes' => $attributes, + ]; + + return ExportJson::encode(['event_id' => $eventId, 'sent_at' => $sentAt]) . "\n" + . ExportJson::encode(['type' => 'log', 'item_count' => 1, 'content_type' => 'application/vnd.sentry.items.log+json']) . "\n" + . ExportJson::encode(['items' => [$log]]) . "\n"; // the trailing newline exists on the log envelope only + } + + public function transaction(Event $event): SentryTransaction + { + $end = $this->seconds($event); + $eventId = TraceContext::spanId($event->id) . TraceContext::spanId(''); + $traceId = TraceContext::traceId($event->sessionId); + $spanId = TraceContext::spanId($event->id); + $op = $event->eventType !== '' ? $event->eventType : 'mcp.event'; + $name = $this->name($event); + $body = new \stdClass(); + $body->type = 'transaction'; + $body->event_id = $eventId; + $duration = $event->duration; + $body->timestamp = $end; + $body->start_timestamp = $duration !== null && Js::truthy($duration) ? $end - $duration / 1000 : $end; // B05: timestamp is the end + $body->transaction = $name; + $body->contexts = $this->contexts($event, ['trace_id' => $traceId, 'span_id' => $spanId, 'op' => $op, 'status' => Js::truthy($event->isError) ? 'internal_error' : 'ok']); + $body->tags = $this->tags($event); + $body->extra = $this->extra($event); + + return new SentryTransaction($eventId, $traceId, $spanId, $op, $end, $name, $body); + } + + public function transactionEnvelope(SentryTransaction $transaction, string $sentAt): string + { + return ExportJson::encode(['event_id' => $transaction->eventId, 'sent_at' => $sentAt]) . "\n" + . ExportJson::encode(['type' => 'transaction']) . "\n" + . ExportJson::encode($transaction->body); + } + + /** @return array{eventId: string, body: \stdClass} */ + public function errorEvent(Event $event, ?SentryTransaction $transaction): array + { + $message = 'Unknown error'; + $type = 'ToolCallError'; + if (is_string($event->error) && $event->error !== '') { + $message = $event->error; + } elseif (Js::truthy($event->error) && Values::isObjectLike($event->error)) { + $message = Values::has($event->error, 'message') ? Js::string(Values::get($event->error, 'message')) : ExportJson::encode($event->error); + if (Values::has($event->error, 'type')) { + $type = Js::string(Values::get($event->error, 'type')); + } + } + $trace = ['trace_id' => $transaction->traceId ?? TraceContext::traceId($event->sessionId), 'span_id' => TraceContext::spanId($event->id)]; + if ($transaction instanceof SentryTransaction) { + $trace['parent_span_id'] = $transaction->spanId; // the same value as span_id (TS self-parent quirk) + } + $trace['op'] = $transaction->op ?? ($event->eventType !== '' ? $event->eventType : 'mcp.event'); + $contexts = $this->contexts($event, $trace); + $mcp = new \stdClass(); + foreach (['resource_name' => $event->resourceName, 'session_id' => $event->sessionId, 'event_type' => $event->eventType, 'user_intent' => $event->userIntent] as $key => $value) { + if ($value !== null) { + $mcp->{$key} = $value; + } + } + $contexts->mcp = $mcp; + $eventId = TraceContext::spanId($event->id) . TraceContext::spanId(''); + $body = new \stdClass(); + $body->type = 'event'; + $body->event_id = $eventId; + $body->timestamp = $transaction->timestamp ?? $this->seconds($event); + $body->level = 'error'; + $body->exception = ['values' => [['type' => $type, 'value' => $message, 'mechanism' => ['type' => 'mcp_tool_call', 'handled' => false]]]]; + $body->contexts = $contexts; + $body->tags = $this->tags($event); + $body->extra = $this->extra($event); + $body->transaction = $transaction->name ?? $this->name($event); + + return ['eventId' => $eventId, 'body' => $body]; + } + + /** @param array{eventId: string, body: \stdClass} $errorEvent */ + public function errorEnvelope(array $errorEvent, string $sentAt): string + { + return ExportJson::encode(['event_id' => $errorEvent['eventId'], 'sent_at' => $sentAt]) . "\n" + . ExportJson::encode(['type' => 'event', 'content_type' => 'application/json']) . "\n" + . ExportJson::encode($errorEvent['body']); + } + + private function tags(Event $event): \stdClass + { + $tags = new \stdClass(); + $tags->source = Constants::SOURCE; + $fields = [ + 'environment' => $this->config->environment, 'release' => $this->config->release, 'event_type' => $event->eventType, + 'resource' => $event->resourceName, 'server_name' => $event->serverName, 'client_name' => $event->clientName, 'actor_id' => $event->identifyActorGivenId, + ]; + foreach ($fields as $key => $value) { + if (Js::truthy($value)) { + $tags->{$key} = $value; + } + } + foreach (Values::entries($event->tags ?? []) as $key => $value) { + $tags->{'agentcat.' . $key} = $value; // not sanitized (D15) + } + + return $tags; + } + + private function extra(Event $event): \stdClass + { + $extra = new \stdClass(); + foreach (['session_id' => $event->sessionId, 'project_id' => $event->projectId, 'user_intent' => $event->userIntent, 'actor_name' => $event->identifyActorName, 'server_version' => $event->serverVersion, 'client_version' => $event->clientVersion] as $key => $value) { + if (Js::truthy($value)) { + $extra->{$key} = $value; + } + } + if ($event->duration !== null) { + $extra->duration_ms = $event->duration; + } + if (Js::truthy($event->error)) { + $extra->error = $event->error; + } + + return $extra; + } + + /** @param array $trace */ + private function contexts(Event $event, array $trace): \stdClass + { + $contexts = new \stdClass(); + $contexts->trace = $trace; + if (Js::truthy($event->properties)) { + $contexts->agentcat = ExportJson::map($event->properties); + } + + return $contexts; + } + + private function name(Event $event): string + { + if (Js::truthy($event->resourceName)) { + return ($event->eventType !== '' ? $event->eventType : 'mcp') . ' - ' . $event->resourceName; + } + + return $event->eventType !== '' ? $event->eventType : 'mcp.event'; + } + + private function seconds(Event $event): int|float + { + return Values::epochMillis($event->timestamp) / 1000; // JS ms / 1000: an int when it divides + } +} diff --git a/src/Internal/Telemetry/Sentry/SentryTransaction.php b/src/Internal/Telemetry/Sentry/SentryTransaction.php new file mode 100644 index 0000000..0aeb31a --- /dev/null +++ b/src/Internal/Telemetry/Sentry/SentryTransaction.php @@ -0,0 +1,23 @@ + $exporters keyed by the configured name + * @param array $configs what `track()` passed, to tell a later different set + */ + public function __construct( + private readonly array $exporters, + private readonly array $configs = [], + ) {} + + /** + * Builds every exporter it can: an unknown entry or an invalid Sentry DSN logs one line and is skipped. + * + * @param array $configs + */ + public static function fromConfigs(array $configs, HttpChannel $http): self + { + $exporters = []; + foreach ($configs as $name => $config) { + try { + $exporter = ExporterFactory::create($config, $http); + } catch (\Throwable $e) { + Log::write(sprintf('Failed to initialize exporter %s: %s', $name, ErrorText::of($e))); + + continue; + } + if ($exporter instanceof Exporter) { + $exporters[$name] = $exporter; + Log::write('Initialized telemetry exporter: ' . $name); + } + } + + return new self($exporters, $configs); + } + + /** + * First wins (TypeScript: process-global; here per queue, which is the process queue outside tests). Null + * changes nothing; a later different set logs one WARN line (09 §5.2); a sink that is not ours stays. + * + * @param array|null $configs + */ + public static function install(EventQueue $queue, ?array $configs): void + { + if ($configs === null) { + return; + } + $sink = $queue->exporterSink(); + if ($sink instanceof NullExporterSink) { + $queue->setExporterSink(self::fromConfigs($configs, $queue->channel())); + Log::write(sprintf('Initialized telemetry with %d exporters', count($configs))); + } elseif ($sink instanceof self && !$sink->warned && !self::same($configs, $sink->configs)) { + $sink->warned = true; // once per process, only for a different set + Log::write(self::IGNORED); + } + } + + #[\Override] + public function export(Event $event): void + { + foreach ($this->exporters as $name => $exporter) { + $reject = static function (string $message) use ($name): void { + Log::write(sprintf('Telemetry export failed for %s: %s', $name, $message)); + }; + try { + $exporter->export($event, $reject); + } catch (\Throwable $e) { + $reject(ExportErrors::message($e)); + } + } + } + + public function count(): int + { + return count($this->exporters); + } + + /** + * Ruling P3: a set that cannot be serialized (a closure) or whose serialization warns counts as different, and + * nothing throws or reaches the output (D32). + * + * @param array $configs + * @param array $installed + */ + private static function same(array $configs, array $installed): bool + { + try { + return StrictErrors::run(static fn(): bool => serialize($configs) === serialize($installed)); + } catch (\Throwable) { + return false; + } + } +} diff --git a/src/Internal/Telemetry/TraceContext.php b/src/Internal/Telemetry/TraceContext.php new file mode 100644 index 0000000..c1f32e8 --- /dev/null +++ b/src/Internal/Telemetry/TraceContext.php @@ -0,0 +1,43 @@ + the requests in transfer */ + private readonly CurlMulti $multi; + + private readonly int $connectTimeoutMs; + + private readonly int $totalTimeoutMs; + + /** @var list */ + private array $waiting = []; + + /** The body bytes of `$waiting`. */ + private int $waitingBytes = 0; + + /** Requests dropped from a full queue since the last pass; `pump()` logs them in one line. */ + private int $dropped = 0; + + /** Set by the write and header functions while they run. */ + private readonly CallbackFlag $inCallback; + + /** + * @param (\Closure(string): bool)|null $functionExists tests only: tells whether a curl function exists. Default `function_exists()`. + * @param (\Closure(string, int): int)|null $multiStatus tests only: receives the function name and the CURLM code of each + * `curl_multi_add_handle()` and `curl_multi_exec()` call, returns the code to act on + * @param (\Closure(\Throwable): void)|null $onSwallowed tests only: sees every throwable the channel swallows (D32); must not throw + * @param (\Closure(string, CallbackFlag): void)|null $onCallback tests only: runs inside every write and header + * callback (`write` or `header`), where a signal handler can + * run too, with the busy flag: a test clears it to stand for + * PHP 8.4+'s callback entry + * + * @throws \InvalidArgumentException for a timeout curl cannot take (`CurlSupport::timeoutMilliseconds()`) + */ + public function __construct( + float $connectTimeoutSeconds = CurlTransport::CONNECT_TIMEOUT_SECONDS, + float $totalTimeoutSeconds = CurlTransport::TOTAL_TIMEOUT_SECONDS, + ?\Closure $functionExists = null, + ?\Closure $multiStatus = null, + private readonly ?\Closure $onSwallowed = null, + private readonly ?\Closure $onCallback = null, + ) { + $this->connectTimeoutMs = CurlSupport::timeoutMilliseconds($connectTimeoutSeconds); + $this->totalTimeoutMs = CurlSupport::timeoutMilliseconds($totalTimeoutSeconds); + $this->curl = new CurlSupport(self::NO_CURL, $functionExists); + $this->multi = new CurlMulti($multiStatus); + $this->inCallback = new CallbackFlag(); + } + + #[\Override] + public function post(HttpRequest $request, \Closure $onDone): void + { + if (!$this->curl->usable()) { + return; // dropped: nothing can send it + } + $bytes = strlen($request->body); + // The oldest go until this one fits; it is never dropped itself, even when its body alone is over the budget. + while ($this->waiting !== [] && (count($this->waiting) >= self::MAX_WAITING || $this->waitingBytes + $bytes > self::MAX_WAITING_BYTES)) { + $oldest = array_shift($this->waiting); + $this->waitingBytes -= strlen($oldest['request']->body); + ++$this->dropped; + } + $this->waiting[] = ['request' => $request, 'onDone' => $onDone]; + $this->waitingBytes += $bytes; + } + + /** Also logs the requests dropped since the last pass, in one line. Does no curl work while `busy()`. */ + #[\Override] + public function pump(): void + { + if (!$this->busy() && ($this->waiting !== [] || $this->multi->count() > 0)) { // else nothing posted (or nothing can be): no curl call + $this->guarded(function (): void { + $this->launch(); + if ($this->collect()) { + $this->launch(); // requests that callbacks posted (Sentry's sequence) start in this pass + } + }); + } + if ($this->dropped > 0) { + Log::write(sprintf(self::FULL, $this->dropped)); + $this->dropped = 0; + } + } + + /** 0 while `busy()`: inside its own callback nothing can progress, so a drain there has nothing to wait for. */ + #[\Override] + public function pending(bool $includeBackground = true): int + { + if ($this->busy()) { + return 0; + } + if ($includeBackground) { + return count($this->waiting) + $this->multi->count(); + } + $pending = 0; + foreach ($this->waiting as $waiting) { + $pending += $waiting['request']->background ? 0 : 1; + } + foreach ($this->multi->payloads() as $transfer) { + $pending += $transfer->background ? 0 : 1; + } + + return $pending; + } + + #[\Override] + public function waitForActivity(float $seconds): void + { + if ($this->multi->count() === 0 || $this->busy()) { + return; // a waiting request starts at the next pump(); inside a callback nothing can progress + } + $timeout = CurlSupport::waitSeconds($seconds, self::MAX_WAIT_SECONDS); + $this->guarded(function () use ($timeout): void { + $this->multi->select($timeout); + }); + } + + /** Transfers in the multi handle (tests). */ + public function inTransfer(): int + { + return $this->multi->count(); + } + + /** + * True while one of its PHP callbacks runs, inside `curl_multi_exec()`. An `exit()` in a signal handler there ends + * the callback before it clears the flag; the flag is cleared once no `curl_multi_exec()` is on the stack (the + * shutdown functions run after the stack unwound). + */ + private function busy(): bool + { + if (!$this->inCallback->active) { + return false; + } + foreach (debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS) as $frame) { + if ($frame['function'] === 'curl_multi_exec') { + return true; + } + } + $this->inCallback->active = false; + + return false; + } + + /** @param \Closure(): void $work */ + private function guarded(\Closure $work): void + { + set_error_handler(static fn(): bool => true); + + try { + $work(); + } catch (\Throwable $error) { + $this->swallowed($error); // a side request must never reach the host (D32) + } + restore_error_handler(); + } + + private function swallowed(\Throwable $error): void + { + if ($this->onSwallowed instanceof \Closure) { + ($this->onSwallowed)($error); + } + } + + /** Starts waiting requests while slots are free; the next collect() runs their first curl_multi_exec(). */ + private function launch(): void + { + while ($this->multi->count() < self::MAX_CONCURRENT && $this->waiting !== []) { + $next = array_shift($this->waiting); + $this->waitingBytes -= strlen($next['request']->body); + $transfer = new HttpTransfer($next['onDone'], $next['request']->background); + + try { + $error = $this->multi->open($this->curlOptions($next['request'], $transfer), $transfer); + } catch (\Throwable $exception) { + $error = ErrorText::of($exception); // a URL with a NUL byte (ValueError): this request fails, the others go on + } + if ($error !== null) { + $this->finish($transfer->onDone, HttpResponse::failed($error)); + } + } + } + + /** + * Runs the transfers, then runs the callback of every transfer that this run finished (or that a failed run + * dropped). False when libcurl refused the run: the call came from inside one of its callbacks, and from now until + * that callback ends the channel is `busy()`. + */ + private function collect(): bool + { + $ran = $this->multi->run( + function (HttpTransfer $transfer, \CurlHandle $handle, int $result): void { + $this->finish($transfer->onDone, $result === CURLE_OK + ? new HttpResponse(curl_getinfo($handle, CURLINFO_RESPONSE_CODE), $transfer->reason, $transfer->body) + : HttpResponse::failed(CurlSupport::transferError($handle, $result))); + }, + function (HttpTransfer $transfer, string $error): void { + $this->finish($transfer->onDone, HttpResponse::failed($error)); + }, + ); + if (!$ran) { + $this->inCallback->active = true; // the callback clears it when it ends + } + + return $ran; + } + + /** @param \Closure(HttpResponse): void $onDone */ + private function finish(\Closure $onDone, HttpResponse $response): void + { + try { + $onDone($response); + } catch (\Throwable $error) { + $this->swallowed($error); // our callbacks log and never throw; a broken one must not stop the others + } + } + + /** + * The options of `$request`: the write and header functions first (`CurlSupport::postOptions()`). They hold only + * `$transfer` and the callback flag, so a finished handle frees the transfer and no handle holds the channel. The + * request's timeouts win over the channel's (diagnostics: 250 ms and 1 s, D49). + * + * Each callback sets the flag as its first statement and clears it as its last one before `return`, with no call + * and no jump after it. PHP runs a pending signal after a function call and at a jump (PHP 8.3, 8.4 and 8.5), and + * PHP 8.4+ also at the entry of a callback that C calls. So a handler inside the callback sees the flag, except one + * that PHP 8.4+ runs at the entry; for that one, the first `curl_multi_exec()` it attempts is refused, and the + * refusal marks the channel busy (`collect()`). A `finally` would not do: leaving it jumps back to the `return`, + * and a handler run there would see the flag cleared while libcurl is still in `curl_multi_exec()`. An `exit()` or + * a throwable inside the callback leaves the flag set; `busy()` clears it once no `curl_multi_exec()` is on the + * stack. + * + * @return array + * + * @throws \InvalidArgumentException for a request timeout curl cannot take: that request fails + */ + public function curlOptions(HttpRequest $request, HttpTransfer $transfer): array + { + $headers = []; + foreach ($request->headers as $name => $value) { + $headers[] = $name . ': ' . $value; + } + $inCallback = $this->inCallback; + $onCallback = $this->onCallback; + $capture = [ + CURLOPT_WRITEFUNCTION => static function (\CurlHandle $handle, string $data) use ($transfer, $inCallback, $onCallback): int { + $inCallback->active = true; + if ($onCallback instanceof \Closure) { + $onCallback('write', $inCallback); + } + // Up to MAX_BODY_BYTES are kept; the rest is read and dropped. + $transfer->body .= substr($data, 0, self::MAX_BODY_BYTES - strlen($transfer->body)); + $taken = strlen($data); // a shorter return value would abort the transfer + $inCallback->active = false; // last: see curlOptions() + + return $taken; + }, + CURLOPT_HEADERFUNCTION => static function (\CurlHandle $handle, string $line) use ($transfer, $inCallback, $onCallback): int { + $inCallback->active = true; + if ($onCallback instanceof \Closure) { + $onCallback('header', $inCallback); + } + if (preg_match(self::STATUS_LINE, $line, $match) === 1) { + $transfer->reason = $match[1] ?? ''; + } + $taken = strlen($line); + $inCallback->active = false; // last: see curlOptions() + + return $taken; + }, + ]; + + $connectTimeoutMs = $request->connectTimeoutSeconds === null ? $this->connectTimeoutMs : CurlSupport::timeoutMilliseconds($request->connectTimeoutSeconds); + $totalTimeoutMs = $request->totalTimeoutSeconds === null ? $this->totalTimeoutMs : CurlSupport::timeoutMilliseconds($request->totalTimeoutSeconds); + + return CurlSupport::postOptions($capture, $request->url, $request->body, $headers, $connectTimeoutMs, $totalTimeoutMs); + } +} diff --git a/src/Internal/Transport/CurlMulti.php b/src/Internal/Transport/CurlMulti.php new file mode 100644 index 0000000..08d1a99 --- /dev/null +++ b/src/Internal/Transport/CurlMulti.php @@ -0,0 +1,175 @@ + transfers in `$multi`, keyed by `spl_object_id($handle)` */ + private array $transfers = []; + + /** @var \Closure(string, int): int */ + private readonly \Closure $multiStatus; + + /** @var array the ids of the transfers whose add libcurl refused from inside a callback: the next `run()` adds them */ + private array $unadded = []; + + /** + * @param (\Closure(string, int): int)|null $multiStatus tests only: receives the function name and the CURLM code of each + * `curl_multi_add_handle()` and `curl_multi_exec()` call, returns the code to act on + */ + public function __construct(?\Closure $multiStatus = null) + { + $this->multiStatus = $multiStatus ?? static fn(string $function, int $code): int => $code; + } + + /** + * Starts a transfer with `$options`. Null when it runs, or waits for the next `run()` (an add from inside a + * callback); else the error text, and nothing of it is left behind. A throwable of curl_setopt_array() (a NUL byte in + * a string option) reaches the caller. + * + * @param array $options + * @param TPayload $payload + */ + public function open(array $options, mixed $payload): ?string + { + $handle = curl_init(); + if ($handle === false) { + return 'cURL error: curl_init() failed'; + } + if (!curl_setopt_array($handle, $options)) { + return 'cURL error: curl_setopt_array() failed'; // a rejected option, such as a URL over 8 MB: never added + } + // Recorded before the add: a signal drain that runs right after the add must see this transfer. + $id = spl_object_id($handle); + $this->transfers[$id] = ['handle' => $handle, 'payload' => $payload]; + + return $this->add($id, $handle); + } + + /** + * Runs the transfers, then hands each transfer this run finished to `$finished` with its handle and CURLE + * result. When the multi handle cannot run them, every transfer goes to `$failed` with the error text first, + * so that nothing waits for them forever. Called from inside a callback of the running `curl_multi_exec()`, it + * changes nothing (libcurl refuses the nested call) and returns false. + * + * @param \Closure(TPayload, \CurlHandle, int): void $finished + * @param \Closure(TPayload, string): void $failed + */ + public function run(\Closure $finished, \Closure $failed): bool + { + foreach (array_intersect_key($this->transfers, $this->unadded) as $id => $transfer) { + $error = $this->add($id, $transfer['handle']); + if ($error !== null) { + $failed($transfer['payload'], $error); + } + } + $result = curl_multi_exec($this->multi(), $running); // its own statement: see the class docblock + $code = ($this->multiStatus)('curl_multi_exec', $result); + if ($code === self::RECURSIVE_API_CALL) { + return false; // from inside a callback of the running curl_multi_exec(): every transfer stays as it is + } + if ($code !== CURLM_OK) { + $transfers = $this->transfers; + $this->transfers = []; + $this->unadded = []; + foreach ($transfers as $transfer) { + curl_multi_remove_handle($this->multi(), $transfer['handle']); + $failed($transfer['payload'], CurlSupport::multiError($code)); + } + } + while (true) { + /** @var array{msg: int, result: int, handle: \CurlHandle}|false $info the documented shape */ + $info = curl_multi_info_read($this->multi()); + if ($info === false) { + return true; + } + $id = spl_object_id($info['handle']); + $transfer = $this->transfers[$id] ?? null; + unset($this->transfers[$id]); + curl_multi_remove_handle($this->multi(), $info['handle']); + if ($transfer !== null) { + $finished($transfer['payload'], $info['handle'], $info['result']); + } + } + } + + /** Blocks until a transfer can progress or `$seconds` pass (0 or more, see `CurlSupport::waitSeconds()`). Does no work. */ + public function select(float $seconds): void + { + if (curl_multi_select($this->multi(), $seconds) === -1) { + usleep(1000); // the wait failed: a caller's loop must not spin + } + } + + /** Transfers in the multi handle (or waiting for the next `run()` to add them). */ + public function count(): int + { + return count($this->transfers); + } + + /** @return list the payloads of the transfers in the multi handle */ + public function payloads(): array + { + return array_column($this->transfers, 'payload'); + } + + /** + * Adds transfer `$id`. Null when libcurl took it, or refused it from inside a callback (it then waits for the next + * `run()`); else the error text, and the transfer is gone. + */ + private function add(int $id, \CurlHandle $handle): ?string + { + $result = curl_multi_add_handle($this->multi(), $handle); // its own statement: see the class docblock + $code = ($this->multiStatus)('curl_multi_add_handle', $result); + if ($code === CURLM_OK) { + unset($this->unadded[$id]); + + return null; + } + // PHP keeps the handle in its list of the multi handle even when libcurl refused it. libcurl never took this + // one, so the remove frees nothing that libcurl uses. + curl_multi_remove_handle($this->multi(), $handle); + if ($code === self::RECURSIVE_API_CALL) { + $this->unadded[$id] = $id; + + return null; + } + unset($this->transfers[$id], $this->unadded[$id]); + + return CurlSupport::multiError($code); + } + + private function multi(): \CurlMultiHandle + { + return $this->multi ??= curl_multi_init(); + } +} diff --git a/src/Internal/Transport/CurlSupport.php b/src/Internal/Transport/CurlSupport.php new file mode 100644 index 0000000..d0da7c9 --- /dev/null +++ b/src/Internal/Transport/CurlSupport.php @@ -0,0 +1,142 @@ +functionExists = $functionExists ?? function_exists(...); + } + + /** Whether every curl function exists. The first call checks them; a false answer logs one line. */ + public function usable(): bool + { + if ($this->usable === null) { + $missing = array_filter(self::FUNCTIONS, fn(string $function): bool => !($this->functionExists)($function)); + $this->usable = $missing === []; + if (!$this->usable) { + Log::write(sprintf($this->missingLine, implode(', ', $missing))); + } + } + + return $this->usable; + } + + /** True once `usable()` answered true. Checks nothing. */ + public function confirmed(): bool + { + return $this->usable === true; + } + + /** `agentcat-php/ php/`. */ + public static function userAgent(): string + { + return 'agentcat-php/' . SdkVersion::get() . ' php/' . PHP_VERSION; + } + + /** + * `$seconds` in whole milliseconds, for `CURLOPT_CONNECTTIMEOUT_MS` and `CURLOPT_TIMEOUT_MS`. + * + * @throws \InvalidArgumentException for NAN, for less than 0.0005 (it would round to 0 ms, curl's "no timeout", or + * to a negative value curl rejects) and for more than MAX_TIMEOUT_SECONDS (INF + * included): a programming error, the queue passes constants + */ + public static function timeoutMilliseconds(float $seconds): int + { + // Checked before the cast: on PHP 8.5, (int) of a float that is not finite or beyond the int range warns. + if (is_nan($seconds) || $seconds < 0.0005 || $seconds > self::MAX_TIMEOUT_SECONDS) { + throw new \InvalidArgumentException(sprintf( + 'A curl timeout must round to 1 through 2147483647 milliseconds, got %s seconds', + var_export($seconds, true), + )); + } + + return (int) round($seconds * 1000); + } + + /** The bound of one wait: `$seconds` within 0 and `$max`. NAN is 0 before `min()`/`max()` see it (PHP 8.4 changed them). */ + public static function waitSeconds(float $seconds, float $max): float + { + return is_nan($seconds) ? 0.0 : max(0.0, min($seconds, $max)); + } + + /** + * The options of one `POST`. `$capture`, the options that take the response (`CURLOPT_RETURNTRANSFER`, or the + * write and header functions), come first: curl_setopt_array() stops at the first option it rejects, and a + * handle without them would print the response body to STDOUT, the stdio MCP stream. Then the request: + * `$headers` (`Name: value` lines) and the SDK User-Agent, http and https only, no redirects, no signals, and + * the connect and total timeouts. The response headers stay out of the body. + * + * @param array $capture + * @param list $headers + * + * @return array + */ + public static function postOptions(array $capture, string $url, string $body, array $headers, int $connectTimeoutMs, int $totalTimeoutMs): array + { + return $capture + [ + CURLOPT_URL => $url, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $body, + // A blank `Expect:` stops libcurl from waiting for `100 Continue` on large bodies. + CURLOPT_HTTPHEADER => [...$headers, 'Expect:'], + CURLOPT_USERAGENT => self::userAgent(), + CURLOPT_HEADER => false, + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, + CURLOPT_NOSIGNAL => true, + CURLOPT_CONNECTTIMEOUT_MS => $connectTimeoutMs, + CURLOPT_TIMEOUT_MS => $totalTimeoutMs, + ]; + } + + /** `cURL error : ` for a finished transfer whose result is not `CURLE_OK`. */ + public static function transferError(\CurlHandle $handle, int $result): string + { + $text = curl_error($handle); + + return sprintf('cURL error %d: %s', $result, $text !== '' ? $text : curl_strerror($result)); + } + + /** `cURL multi error : ` for a `curl_multi_*` call that did not return `CURLM_OK`. */ + public static function multiError(int $code): string + { + $message = curl_multi_strerror($code); // not nested in sprintf()'s arguments: see `CurlMulti` + + return sprintf('cURL multi error %d: %s', $code, $message); + } +} diff --git a/src/Internal/Transport/CurlTransport.php b/src/Internal/Transport/CurlTransport.php index 6f2e904..df767db 100644 --- a/src/Internal/Transport/CurlTransport.php +++ b/src/Internal/Transport/CurlTransport.php @@ -5,7 +5,6 @@ namespace AgentCat\Internal\Transport; use AgentCat\Internal\Logging\Log; -use AgentCat\Internal\SdkVersion; use AgentCat\Internal\Support\ErrorText; /** @@ -31,27 +30,18 @@ final class CurlTransport implements Transport private const string NO_CURL = 'WARN: AgentCat cannot send events: ext-curl functions are missing or disabled (%s)'; - /** Every curl function this class calls. */ - private const array FUNCTIONS = [ - 'curl_init', 'curl_setopt_array', 'curl_error', 'curl_strerror', 'curl_getinfo', 'curl_multi_init', - 'curl_multi_add_handle', 'curl_multi_remove_handle', 'curl_multi_exec', 'curl_multi_info_read', - 'curl_multi_select', 'curl_multi_strerror', - ]; + /** The ext-curl check, made at the first delivery. */ + private readonly CurlSupport $curl; - /** Null until the first transfer. */ - private ?\CurlMultiHandle $multi = null; - - /** Whether every function in FUNCTIONS exists; null until the first delivery checked it. */ - private ?bool $usable = null; - - /** @var \Closure(string): bool */ - private readonly \Closure $functionExists; + /** @var CurlMulti the attempts in transfer */ + private readonly CurlMulti $multi; /** @var \Closure(): float */ private readonly \Closure $clock; - /** @var \Closure(string, int): int */ - private readonly \Closure $multiStatus; + private readonly int $connectTimeoutMs; + + private readonly int $totalTimeoutMs; /** @var list attempts waiting for a free transfer slot */ private array $ready = []; @@ -59,37 +49,39 @@ final class CurlTransport implements Transport /** @var list retries waiting for their time */ private array $scheduled = []; - /** @var array transfers in `$multi`, keyed by `spl_object_id($handle)` */ - private array $active = []; - /** * @param (\Closure(): float)|null $clock monotonic seconds (tests); must not throw. Default `hrtime(true) / 1e9`. * @param (\Closure(string, int): int)|null $multiStatus tests only: receives the function name and the CURLM code of each * `curl_multi_add_handle()` and `curl_multi_exec()` call, returns the code to act on * @param (\Closure(string): bool)|null $functionExists tests only: tells whether a curl function exists. Default `function_exists()`. + * + * @throws \InvalidArgumentException for a timeout curl cannot take (`CurlSupport::timeoutMilliseconds()`) */ public function __construct( private readonly RetryPolicy $retryPolicy = new RetryPolicy(), - private readonly float $connectTimeoutSeconds = self::CONNECT_TIMEOUT_SECONDS, - private readonly float $totalTimeoutSeconds = self::TOTAL_TIMEOUT_SECONDS, + float $connectTimeoutSeconds = self::CONNECT_TIMEOUT_SECONDS, + float $totalTimeoutSeconds = self::TOTAL_TIMEOUT_SECONDS, ?\Closure $clock = null, ?\Closure $multiStatus = null, ?\Closure $functionExists = null, ) { + $this->connectTimeoutMs = CurlSupport::timeoutMilliseconds($connectTimeoutSeconds); + $this->totalTimeoutMs = CurlSupport::timeoutMilliseconds($totalTimeoutSeconds); $this->clock = $clock ?? static fn(): float => hrtime(true) / 1e9; - $this->multiStatus = $multiStatus ?? static fn(string $function, int $code): int => $code; - $this->functionExists = $functionExists ?? function_exists(...); + $this->curl = new CurlSupport(self::NO_CURL, $functionExists); + $this->multi = new CurlMulti($multiStatus); } + /** `agentcat-php/ php/`, also the User-Agent of the exporters and diagnostics. */ public static function userAgent(): string { - return 'agentcat-php/' . SdkVersion::get() . ' php/' . PHP_VERSION; + return CurlSupport::userAgent(); } #[\Override] public function start(Delivery $delivery): void { - if (!$this->usable()) { + if (!$this->curl->usable()) { return; // dropped: nothing can send it } $this->guarded(function () use ($delivery): void { @@ -102,7 +94,7 @@ public function start(Delivery $delivery): void #[\Override] public function pump(): void { - if ($this->usable !== true) { + if (!$this->curl->confirmed()) { return; // no delivery started yet, or none can be } // Collecting comes last: every curl_multi_exec() is followed by a read of the transfers it finished, so @@ -132,7 +124,7 @@ public function pump(): void public function waitForActivity(float $deadline): void { $this->guarded(function () use ($deadline): void { - if ($this->ready !== [] && count($this->active) < self::MAX_CONCURRENT) { + if ($this->ready !== [] && $this->multi->count() < self::MAX_CONCURRENT) { return; // a waiting attempt can start now (a slot freed up after the last launch) } $now = $this->now(); @@ -141,11 +133,9 @@ public function waitForActivity(float $deadline): void $until = min($until, $retry['due']); } // A passed deadline or an overdue retry gives 0: no wait. - $timeout = max(0.0, min($until - $now, self::MAX_WAIT_SECONDS)); - if ($this->active !== []) { - if (curl_multi_select($this->multi(), $timeout) === -1) { - usleep(1000); - } + $timeout = CurlSupport::waitSeconds($until - $now, self::MAX_WAIT_SECONDS); + if ($this->multi->count() > 0) { + $this->multi->select($timeout); return; } @@ -185,7 +175,7 @@ public function send(Delivery $delivery, float $deadline = INF): bool #[\Override] public function inFlight(): int { - return count($this->ready) + count($this->active) + count($this->scheduled); + return count($this->ready) + $this->multi->count() + count($this->scheduled); } #[\Override] @@ -197,40 +187,9 @@ public function now(): float /** @return array */ public function curlOptions(Delivery $delivery): array { - return [ - CURLOPT_URL => $delivery->url, - CURLOPT_POST => true, - CURLOPT_POSTFIELDS => $delivery->body, - // A blank `Expect:` stops libcurl from waiting for `100 Continue` on large bodies. - CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Accept: application/json', 'Expect:'], - CURLOPT_USERAGENT => self::userAgent(), - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HEADER => false, - CURLOPT_FOLLOWLOCATION => false, - CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, - CURLOPT_NOSIGNAL => true, - CURLOPT_CONNECTTIMEOUT_MS => (int) round($this->connectTimeoutSeconds * 1000), - CURLOPT_TIMEOUT_MS => (int) round($this->totalTimeoutSeconds * 1000), - ]; - } + $headers = ['Content-Type: application/json', 'Accept: application/json']; - /** Whether every curl function this class calls exists. The first false answer logs one line. */ - private function usable(): bool - { - if ($this->usable === null) { - $missing = array_filter(self::FUNCTIONS, fn(string $function): bool => !($this->functionExists)($function)); - $this->usable = $missing === []; - if (!$this->usable) { - Log::write(sprintf(self::NO_CURL, implode(', ', $missing))); - } - } - - return $this->usable; - } - - private function multi(): \CurlMultiHandle - { - return $this->multi ??= curl_multi_init(); + return CurlSupport::postOptions([CURLOPT_RETURNTRANSFER => true], $delivery->url, $delivery->body, $headers, $this->connectTimeoutMs, $this->totalTimeoutMs); } /** @@ -255,7 +214,7 @@ private function guarded(\Closure $work): void /** Starts waiting attempts while slots are free; the next collect() runs their first curl_multi_exec(). */ private function launch(): void { - while (count($this->active) < self::MAX_CONCURRENT && $this->ready !== []) { + while ($this->multi->count() < self::MAX_CONCURRENT && $this->ready !== []) { $next = array_shift($this->ready); $beforeAttempt = $next['delivery']->beforeAttempt; if ($beforeAttempt !== null) { @@ -265,60 +224,32 @@ private function launch(): void // Exporter errors are logged by the queue's closure; they never stop the delivery. } } - $handle = curl_init(); - if ($handle === false) { - $this->failed($next['delivery'], $next['attempt'], 'cURL error: curl_init() failed'); - - continue; - } - curl_setopt_array($handle, $this->curlOptions($next['delivery'])); - // Recorded before the add: a signal drain that runs right after the add must see this transfer. - $id = spl_object_id($handle); - $this->active[$id] = ['handle' => $handle] + $next; - $code = ($this->multiStatus)('curl_multi_add_handle', curl_multi_add_handle($this->multi(), $handle)); - if ($code !== CURLM_OK) { - unset($this->active[$id]); - // PHP keeps the handle in its list of the multi handle even when libcurl refused it. - curl_multi_remove_handle($this->multi(), $handle); - $this->failed($next['delivery'], $next['attempt'], $this->multiError($code)); + $error = $this->multi->open($this->curlOptions($next['delivery']), $next); + if ($error !== null) { + $this->failed($next['delivery'], $next['attempt'], $error); } } } - /** Runs the transfers, then reads every transfer that this run finished. */ + /** Runs the transfers, then reads every transfer that this run finished (or that a failed run dropped). */ private function collect(): void { - $code = ($this->multiStatus)('curl_multi_exec', curl_multi_exec($this->multi(), $running)); - if ($code !== CURLM_OK) { - // The multi handle cannot run its transfers: fail them, so that nothing waits for them forever. - $active = $this->active; - $this->active = []; - foreach ($active as $entry) { - curl_multi_remove_handle($this->multi(), $entry['handle']); - $this->failed($entry['delivery'], $entry['attempt'], $this->multiError($code)); - } - } - while (true) { - /** @var array{msg: int, result: int, handle: \CurlHandle}|false $info the documented shape */ - $info = curl_multi_info_read($this->multi()); - if ($info === false) { - return; - } - $id = spl_object_id($info['handle']); - $entry = $this->active[$id] ?? null; - unset($this->active[$id]); - curl_multi_remove_handle($this->multi(), $info['handle']); - if ($entry !== null) { - $this->finished($entry['delivery'], $entry['attempt'], $info['handle'], $info['result']); - } - } + $this->multi->run( + /** @param array{delivery: Delivery, attempt: int} $attempt */ + function (array $attempt, \CurlHandle $handle, int $result): void { + $this->finished($attempt['delivery'], $attempt['attempt'], $handle, $result); + }, + /** @param array{delivery: Delivery, attempt: int} $attempt */ + function (array $attempt, string $error): void { + $this->failed($attempt['delivery'], $attempt['attempt'], $error); + }, + ); } private function finished(Delivery $delivery, int $attempt, \CurlHandle $handle, int $result): void { if ($result !== CURLE_OK) { - $text = curl_error($handle); - $this->failed($delivery, $attempt, sprintf('cURL error %d: %s', $result, $text !== '' ? $text : curl_strerror($result))); + $this->failed($delivery, $attempt, CurlSupport::transferError($handle, $result)); return; } @@ -331,11 +262,6 @@ private function finished(Delivery $delivery, int $attempt, \CurlHandle $handle, $this->failed($delivery, $attempt, 'HTTP ' . $status); } - private function multiError(int $code): string - { - return sprintf('cURL multi error %d: %s', $code, curl_multi_strerror($code)); - } - private function failed(Delivery $delivery, int $attempt, string $error): void { Log::write(sprintf('Failed to send event %s, retrying... [Error: %s]', $delivery->eventId, $error)); diff --git a/src/Internal/Transport/HttpChannel.php b/src/Internal/Transport/HttpChannel.php new file mode 100644 index 0000000..5d39a59 --- /dev/null +++ b/src/Internal/Transport/HttpChannel.php @@ -0,0 +1,31 @@ + $headers name => value, sent in this order + * @param bool $background AgentCat's own request (diagnostics): `flush()` neither + * counts nor waits for it, `shutdown()` does (`HttpChannel::pending()`) + * @param float|null $connectTimeoutSeconds null: the channel's (`CurlTransport::CONNECT_TIMEOUT_SECONDS`) + * @param float|null $totalTimeoutSeconds null: the channel's (`CurlTransport::TOTAL_TIMEOUT_SECONDS`) + */ + public function __construct( + public string $url, + public array $headers, + public string $body, + public bool $background = false, + public ?float $connectTimeoutSeconds = null, + public ?float $totalTimeoutSeconds = null, + ) {} +} diff --git a/src/Internal/Transport/HttpResponse.php b/src/Internal/Transport/HttpResponse.php new file mode 100644 index 0000000..380570c --- /dev/null +++ b/src/Internal/Transport/HttpResponse.php @@ -0,0 +1,38 @@ +: ` when no answer arrived (TypeScript: the `fetch()` rejection) + */ + public function __construct( + public int $status, + public string $reason, + public string $body, + public ?string $error = null, + ) {} + + /** A request that got no answer. */ + public static function failed(string $error): self + { + return new self(0, '', '', $error); + } + + /** An answer with a 2xx status (`fetch()`'s `response.ok`). */ + public function ok(): bool + { + return $this->error === null && $this->status >= 200 && $this->status < 300; + } +} diff --git a/src/Internal/Transport/HttpTransfer.php b/src/Internal/Transport/HttpTransfer.php new file mode 100644 index 0000000..5b3dd59 --- /dev/null +++ b/src/Internal/Transport/HttpTransfer.php @@ -0,0 +1,29 @@ +|null)|null $eventTags Returns extra tags (string keys and values) for a call. * @param (\Closure(ToolCall): mixed)|null $eventProperties Returns extra JSON-encodable properties for a call. + * @param array|null $exporters Also sends every + * event to your own tools, keyed by a name of your choice. The first track() in the process that passes exporters + * sets them up for every server; later calls change nothing. + * @param bool $disableDiagnostics Turns off diagnostics, which AgentCat uses to find broken installs. Diagnostics send + * every log line of the SDK to AgentCat, with its time and level, also when a PSR-3 logger gets the lines + * instead of agentcat.log. The lines can contain session IDs, actor IDs, tool names, error messages, and the + * answers of exporters that failed, quoted. Each batch also carries your project ID (without one, an install + * ID: a hash of the host name and the install path), the OS type and version, the architecture, the PHP, + * AgentCat SDK and mcp/sdk versions, the process ID and APP_ENV. The first track() in the process decides. + * AGENTCAT_DISABLE_DIAGNOSTICS also turns them off, or on with false; DISABLE_DIAGNOSTICS can only turn + * them off, and when the two disagree, off wins. They are off in test environments. + * AGENTCAT_DIAGNOSTICS_ENDPOINT and AGENTCAT_DIAGNOSTICS_TOKEN change where they go. Each diagnostics + * request waits at most 1 s for a blocked collector; one shutdown can send two, one after the other. + * @param bool $disableSignalHandlers Keeps AgentCat from installing its SIGTERM and SIGINT handlers in CLI servers, so a + * signal ends the process as it did before, without the last delivery. Needed when the process must keep an + * ignored signal that it inherited. Applies only until the handlers are installed, at the first event or log + * line: set it on the first track() of the process. It is process-wide, and the first track() that installs the + * handlers decides; a later call with the switch only logs a warning. Default: false. */ public function __construct( public bool $enableReportMissing = true, @@ -45,6 +70,9 @@ public function __construct( public ?string $apiBaseUrl = null, public ?\Closure $eventTags = null, public ?\Closure $eventProperties = null, + public ?array $exporters = null, + public bool $disableDiagnostics = false, + public bool $disableSignalHandlers = false, ) {} /** @@ -53,6 +81,11 @@ public function __construct( * An unknown key, or a value of the wrong type, logs one warning and the default is used. The bool options * reject an explicit null too. A callable that is not a closure is converted to one. * + * `disableDiagnostics` also takes what an `.env` file gives: int 1 or 0, and the words of + * `AGENTCAT_DISABLE_DIAGNOSTICS` (`true`, `1`, `yes`, `on` turn diagnostics off; `false`, `0`, `no`, `off` and + * blank keep them on; trimmed, any case). Null (`env()` of an unset variable) is not set: the default. Any other + * value logs a warning and turns diagnostics off: an opt-out fails closed. + * * @param array $config */ public static function fromArray(array $config): self @@ -76,6 +109,9 @@ public static function fromArray(array $config): self apiBaseUrl: self::string($config, 'apiBaseUrl'), eventTags: self::closure($config, 'eventTags'), eventProperties: self::closure($config, 'eventProperties'), + exporters: self::exporters($config), + disableDiagnostics: self::disableDiagnostics($config), + disableSignalHandlers: self::bool($config, 'disableSignalHandlers', false), ); } @@ -93,6 +129,42 @@ private static function bool(array $config, string $key, bool $default): bool return $default; } + /** @param array $config */ + private static function disableDiagnostics(array $config): bool + { + $value = $config['disableDiagnostics'] ?? null; + if ($value === null) { + return false; // not set: env() of an unset variable gives null + } + $disabled = DiagnosticsSwitch::option($value); + if ($disabled !== null) { + return $disabled; + } + // A privacy opt-out fails closed. + Log::write(sprintf('WARN: AgentCat option "disableDiagnostics" must be bool, got %s; the value is treated as true', get_debug_type($value))); + + return true; + } + + /** + * @param array $config + * + * @return array|null + */ + private static function exporters(array $config): ?array + { + $value = $config['exporters'] ?? null; + if ($value === null) { + return null; + } + if (is_array($value)) { + return ExporterConfigs::fromArray($value); + } + self::reject('exporters', 'array or null', $value); + + return null; + } + /** @param array $config */ private static function string(array $config, string $key): ?string { diff --git a/src/Testing/AgentCatFake.php b/src/Testing/AgentCatFake.php index 4ed911e..aeaa07e 100644 --- a/src/Testing/AgentCatFake.php +++ b/src/Testing/AgentCatFake.php @@ -36,6 +36,7 @@ public function __construct() $this->client = new Client(ClientSeams::of( new EventQueue(transport: new InstantTransport(), exporters: $this->sink), static fn(): bool => true, + telemetry: false, )); $this->log = FakeLog::attach($this->client); } diff --git a/tests/E2E/Fixtures/http-server.php b/tests/E2E/Fixtures/http-server.php index 589dc19..cbc10ec 100644 --- a/tests/E2E/Fixtures/http-server.php +++ b/tests/E2E/Fixtures/http-server.php @@ -30,6 +30,14 @@ throw new \RuntimeException('e2e boom'); }, 'boom', description: 'Always fails', inputSchema: ['type' => 'object', 'properties' => []]); +// Diagnostics stay off (the inherited AGENTCAT_DISABLE_DIAGNOSTICS=true) and point at a closed port, unless the test +// sends the scenario that this request's diagnostics go to. putenv() lasts for this request only. +$diagnostics = is_string($_SERVER['HTTP_X_E2E_DIAGNOSTICS'] ?? null) ? $_SERVER['HTTP_X_E2E_DIAGNOSTICS'] : null; +if ($diagnostics !== null) { + putenv('AGENTCAT_DIAGNOSTICS_ENDPOINT=' . $diagnostics); + putenv('AGENTCAT_DISABLE_DIAGNOSTICS=false'); +} + AgentCat::track($builder, 'proj_' . str_repeat('e', 27), new Options( identify: static function (ToolCall $call) use ($markers): Identity { if (($call->headers['x-e2e-identify-sleep'] ?? '') === '1') { diff --git a/tests/E2E/HttpEndToEndTest.php b/tests/E2E/HttpEndToEndTest.php index f0a5ba8..0324765 100644 --- a/tests/E2E/HttpEndToEndTest.php +++ b/tests/E2E/HttpEndToEndTest.php @@ -26,6 +26,15 @@ final class HttpEndToEndTest extends TestCase { private const float COLLECTOR_DEADLINE_SECONDS = 5.0; + /** How long the diagnostics test waits for the two requests' diagnostics after the second answer. */ + private const float DIAGNOSTICS_DEADLINE_SECONDS = 8.0; + + /** + * The diagnostics test's collector answers each event after this long, so the per-event line, and the POST that + * carries it, come at least that long after the response: its order check keeps 1 s of slack on a loaded runner. + */ + private const int SLOW_EVENT_MS = 1000; + /** The fixture's `identify` sleeps 3 s with `X-E2E-Identify-Sleep: 1`, before the delivery. */ private const float SLEEPING_HOOK_DEADLINE_SECONDS = 8.0; @@ -65,6 +74,7 @@ public static function setUpBeforeClass(): void self::$server = BuiltInServer::start(__DIR__ . '/Fixtures/http-server.php', [ 'AGENTCAT_ENABLED' => 'true', 'AGENTCAT_API_URL' => self::$fallbackUrl, + 'AGENTCAT_DIAGNOSTICS_ENDPOINT' => MockCollector::closedPortUrl(), // a request without X-E2E-Diagnostics stays local 'HOME' => self::$dir . '/home', 'AGENTCAT_E2E_SESSIONS' => self::$dir . '/sessions', 'AGENTCAT_E2E_MARKERS' => self::$dir . '/markers', @@ -279,6 +289,42 @@ public function testOddHeadersReachTheEventAsStrings(): void $this->assertSame($accept, JsonAssert::at($headers, 'accept')); } + /** + * D47 (open question Q3): under `php -S`, like PHP-FPM, each request posts its own diagnostics once, at its end, + * after its response. Each POST is tied to its request by the session in its `Successfully sent event` line: each + * call mints its own session. A slow runner can add a POST without that line, from the 2 s window. + */ + #[Group('ac-06-62')] + #[MaximumDuration(15000)] + public function testEachRequestSendsItsOwnDiagnosticsAfterTheResponse(): void + { + $this->scenario = $this->collector()->scenario([], ['status' => 202, 'delayMs' => self::SLOW_EVENT_MS, 'body' => '{"status":"accepted"}']); + $diagnostics = $this->collector()->scenario(); + $client = $this->client(); + $client->initialize(); + + $answeredAt = []; // when the client had each answer, by the session that the call minted + foreach (['one', 'two'] as $msg) { + $answer = $client->callTool('echo', ['msg' => $msg, 'session_id' => 'start'], $this->headers(['X-E2E-Diagnostics' => $diagnostics])); + $result = HttpHarness::result($answer['message']); + $this->assertSame('echo: ' . $msg, JsonAssert::at($result, 'content', 1, 'text')); + $answeredAt[$this->issuedSessionId($result)] = $answer['receivedAt']; + } + + $this->assertCount(2, $answeredAt, 'each call minted its own session'); + $sent = $this->diagnosticsWithASentEvent($diagnostics, 2); + $this->assertCount(2, $sent, 'one diagnostics POST with the per-event line per tracked request'); + foreach ($answeredAt as $sessionId => $receivedAt) { + $own = array_values(array_filter($sent, fn(\stdClass $request): bool => str_contains($this->body($request), ' | session ' . $sessionId . ' | '))); + $this->assertCount(1, $own, 'one diagnostics POST carries the event line of ' . $sessionId); + $this->assertSame('/v1/logs', $own[0]->path); + $this->assertSame(1, substr_count($this->body($own[0]), 'Successfully sent event '), 'one line each'); + $start = $own[0]->start; + $this->assertIsFloat($start); + $this->assertGreaterThan($receivedAt, $start, 'the diagnostics POST of ' . $sessionId . ' ran after its response'); + } + } + /** A call without `X-E2E-Collector` falls back to `AGENTCAT_API_URL`, a closed local port: never the default API. */ #[MaximumDuration(10000)] public function testWithoutACollectorHeaderTheEventGoesToTheLocalFallback(): void @@ -334,6 +380,37 @@ private function event(float $deadlineSeconds = self::COLLECTOR_DEADLINE_SECONDS return $event; } + /** + * Waits until `$count` diagnostics requests of the scenario carry a `Successfully sent event` line, or the deadline + * passes, and returns those requests. + * + * @return list<\stdClass> + */ + private function diagnosticsWithASentEvent(string $scenario, int $count): array + { + $deadline = microtime(true) + self::DIAGNOSTICS_DEADLINE_SECONDS; + do { + $sent = array_values(array_filter( + $this->collector()->requests($scenario), + fn(\stdClass $request): bool => str_contains($this->body($request), 'Successfully sent event '), + )); + if (count($sent) >= $count) { + break; + } + usleep(20_000); + } while (microtime(true) < $deadline); + + return $sent; + } + + private function body(\stdClass $request): string + { + $body = $request->body ?? null; + $this->assertIsString($body); + + return $body; + } + /** How often the server's agentcat.log says that a request rebuilt the registries. */ private function rebuildLines(): int { @@ -364,6 +441,16 @@ private function log(): string return $log; } + /** The `ses_` ID of the issued block at the start of an answer. */ + private function issuedSessionId(\stdClass $result): string + { + $text = JsonAssert::at($result, 'content', 0, 'text'); + $this->assertIsString($text); + $this->assertSame(1, preg_match('/^session_id: (ses_[0-9A-Za-z]{27})$/m', $text, $match), $text); + + return $match[1]; + } + private function issued(string $sessionId): string { return implode("\n", [Constants::MINT_BACK_HEADER_ISSUED, 'session_id: ' . $sessionId, Constants::MINT_BACK_ISSUED_BODY]); diff --git a/tests/E2E/StdioEndToEndTest.php b/tests/E2E/StdioEndToEndTest.php index cde2ded..fed39a0 100644 --- a/tests/E2E/StdioEndToEndTest.php +++ b/tests/E2E/StdioEndToEndTest.php @@ -5,6 +5,7 @@ namespace AgentCat\Tests\E2E; use AgentCat\Internal\Constants; +use AgentCat\Internal\Diagnostics\Diagnostics; use AgentCat\Internal\Json\Json; use AgentCat\Tests\E2E\Support\StdioMcpClient; use AgentCat\Tests\Integration\Support\MockCollector; @@ -241,6 +242,35 @@ public function testSigtermDeliversTheQueueAndThenEndsTheProcessBySignal(): void $this->assertStdoutIsOnlyJsonRpc($client); } + /** + * 06 AC-62 in a real process: stdin EOF posts the diagnostics buffer exactly once, with the setup beacons and the + * per-event line logged during the EOF drain. + */ + #[Group('ac-06-62')] + #[MaximumDuration(15000)] + public function testStdinEofSendsTheDiagnosticsOnce(): void + { + $diagnostics = $this->collector()->scenario(); + $startedAt = microtime(true); + $client = $this->start(env: ['AGENTCAT_DISABLE_DIAGNOSTICS' => 'false', 'AGENTCAT_DIAGNOSTICS_ENDPOINT' => $diagnostics]); + $this->handshake($client); + $this->resultOf($client, 'tools/call', ['name' => 'echo', 'arguments' => ['msg' => 'one', 'session_id' => 'start']]); + + $this->assertExitsAtEof($client); + + // The server lived less than the 2 s window, so no handler-entry pump and no shutdown pass could post the + // buffer early: the EOF flush is the only POST. Counted after the exit, so a late duplicate is seen too. + $lifetime = microtime(true) - $startedAt; + $this->assertLessThan(Diagnostics::BATCH_FLUSH_SECONDS, $lifetime, 'the server ended inside the 2 s window'); + $requests = $this->collector()->requests($diagnostics); + $this->assertCount(1, $requests, sprintf('one diagnostics POST; the server lived %.3f s', $lifetime)); + $this->assertSame('/v1/logs', $requests[0]->path); + $body = $this->body($requests[0]); + $this->assertStringContainsString('"stringValue":"php"', $body); + $this->assertStringContainsString('AgentCat setup complete | project proj_', $body); + $this->assertSame(1, substr_count($body, 'Successfully sent event '), 'the per-event line of the EOF drain'); + } + /** mcp/sdk main serves the 2026-07-28 era over stdio when the first request carries its envelope. */ #[MaximumDuration(15000)] public function testAModernEraFirstRequestIsTrackedWithoutAHandshake(): void @@ -265,12 +295,15 @@ public function testAModernEraFirstRequestIsTrackedWithoutAHandshake(): void } /** - * Starts this test's server with its own collector scenario and HOME. `AGENTCAT_API_URL` is a closed local port, so - * an event without the scenario can never reach the AgentCat API. + * Starts this test's server with its own collector scenario and HOME. `AGENTCAT_API_URL` and + * `AGENTCAT_DIAGNOSTICS_ENDPOINT` are closed local ports, so an event without the scenario can never reach the + * AgentCat API, and diagnostics, which stay off by the inherited `AGENTCAT_DISABLE_DIAGNOSTICS=true`, can never reach + * AgentCat's collector. * * @param array{status: int, delayMs?: int, body?: string} $default the collector's answer to every event + * @param array $env more environment variables of the server, applied last */ - private function start(array $default = ['status' => 202, 'body' => '{"status":"accepted"}']): StdioMcpClient + private function start(array $default = ['status' => 202, 'body' => '{"status":"accepted"}'], array $env = []): StdioMcpClient { $this->scenario = $this->collector()->scenario([], $default); $this->home = self::$dir . '/home-' . bin2hex(random_bytes(6)); @@ -278,8 +311,10 @@ private function start(array $default = ['status' => 202, 'body' => '{"status":" $this->client = StdioMcpClient::start(__DIR__ . '/Fixtures/stdio-server.php', [ 'AGENTCAT_ENABLED' => 'true', 'AGENTCAT_API_URL' => MockCollector::closedPortUrl(), + 'AGENTCAT_DIAGNOSTICS_ENDPOINT' => MockCollector::closedPortUrl(), 'AGENTCAT_E2E_COLLECTOR' => $this->scenario, 'HOME' => $this->home, + ...$env, ]); return $this->client; @@ -387,6 +422,15 @@ private function eventWithMessage(string $msg, float $deadlineSeconds): ?\stdCla return null; } + /** The body of a request the collector recorded. */ + private function body(\stdClass $request): string + { + $body = $request->body ?? null; + $this->assertIsString($body); + + return $body; + } + /** The server's agentcat.log. */ private function log(): string { diff --git a/tests/Golden/ConstantsTest.php b/tests/Golden/ConstantsTest.php index 5ec43d5..bae811e 100644 --- a/tests/Golden/ConstantsTest.php +++ b/tests/Golden/ConstantsTest.php @@ -135,6 +135,8 @@ public static function shortConstants(): iterable yield 'SOURCE' => ['agentcat', Constants::SOURCE]; yield 'DIAGNOSTICS_SCOPE_NAME' => ['agentcat-diagnostics', Constants::DIAGNOSTICS_SCOPE_NAME]; yield 'DEFAULT_API_BASE_URL' => ['https://api.agentcat.com', Constants::DEFAULT_API_BASE_URL]; + yield 'DEFAULT_DIAGNOSTICS_ENDPOINT' => ['https://otel.agentcat.com', Constants::DEFAULT_DIAGNOSTICS_ENDPOINT]; + yield 'DEFAULT_DIAGNOSTICS_TOKEN' => ['dgk_sdk_diag_3f9a2c7e1b8d4065af2e9c1d7b6a4f80', Constants::DEFAULT_DIAGNOSTICS_TOKEN]; } #[DataProvider('shortConstants')] diff --git a/tests/Golden/DiagnosticsGoldenTest.php b/tests/Golden/DiagnosticsGoldenTest.php new file mode 100644 index 0000000..b87f5ff --- /dev/null +++ b/tests/Golden/DiagnosticsGoldenTest.php @@ -0,0 +1,79 @@ +list(JsonAssert::at($golden, 'resourceLogs', 0, 'resource', 'attributes')) as $attribute) { + $key = JsonAssert::at($attribute, 'key'); + $value = JsonAssert::at($attribute, 'value', 'stringValue'); + $this->assertIsString($key); + $this->assertIsString($value); + $resource[] = ['key' => $key, 'value' => ['stringValue' => $value]]; + } + $records = []; + foreach ($this->list(JsonAssert::at($golden, 'resourceLogs', 0, 'scopeLogs', 0, 'logRecords')) as $record) { + $records[] = [ + 'timeUnixNano' => JsonAssert::at($record, 'timeUnixNano'), + 'severityNumber' => JsonAssert::at($record, 'severityNumber'), + 'severityText' => JsonAssert::at($record, 'severityText'), + 'body' => JsonAssert::at($record, 'body'), + 'attributes' => JsonAssert::at($record, 'attributes'), + ]; + } + + $body = DiagnosticsPayload::encode($resource, $records, '2.1.1-beta.1'); + + $this->assertSame(Json::encodeJs($golden), ExporterGolden::normalized($body)); + } + + #[Group('ac-06-54')] + public function testTheGoldenRequestGoesToTheDefaultCollectorWithTheDefaultToken(): void + { + $golden = ExporterGolden::diagnostics(); + + $this->assertSame(Constants::DEFAULT_DIAGNOSTICS_ENDPOINT . '/v1/logs', $golden['url']); + $this->assertSame(['Content-Type' => 'application/json', 'Authorization' => 'Bearer ' . Constants::DEFAULT_DIAGNOSTICS_TOKEN], get_object_vars($golden['headers'])); + $this->assertSame(Constants::DIAGNOSTICS_SCOPE_NAME, JsonAssert::at($golden['body'], 'resourceLogs', 0, 'scopeLogs', 0, 'scope', 'name')); + } + + #[Group('ac-06-57')] + public function testEveryGoldenSeverityIsTheSeverityOfItsLine(): void + { + $records = $this->list(JsonAssert::at(ExporterGolden::diagnostics()['body'], 'resourceLogs', 0, 'scopeLogs', 0, 'logRecords')); + $this->assertNotSame([], $records); + $texts = []; + foreach ($records as $record) { + $line = JsonAssert::at($record, 'body', 'stringValue'); + $this->assertIsString($line); + $this->assertSame(Severity::of($line), [JsonAssert::at($record, 'severityNumber'), JsonAssert::at($record, 'severityText')]); + $texts[] = JsonAssert::at($record, 'severityText'); + } + $this->assertContains('WARN', $texts); + } + + /** @return list */ + private function list(mixed $value): array + { + $this->assertIsList($value); + + return $value; + } +} diff --git a/tests/Golden/ExporterGoldenTest.php b/tests/Golden/ExporterGoldenTest.php new file mode 100644 index 0000000..cf85afd --- /dev/null +++ b/tests/Golden/ExporterGoldenTest.php @@ -0,0 +1,212 @@ + 'k']))->request(ExporterFixtures::event()); + + $this->assertSame($golden['url'], $request->url); + $this->assertSame(get_object_vars($golden['headers']), $request->headers); + $this->assertSame(ExporterGolden::normalized($golden['body']), ExporterGolden::normalized($request->body)); + } + + #[Group('ac-06-20')] + public function testOtlpErrorMatchesTheGoldenRequest(): void + { + $golden = ExporterGolden::requests('otlp err')[0] ?? throw new \LogicException('golden request missing'); + $request = OtlpPayload::of(new OtlpExporter('http://localhost:4318/v1/traces'))->request(ExporterFixtures::errorEvent()); + + $this->assertSame($golden['url'], $request->url); + $this->assertSame(ExporterGolden::normalized($golden['body']), ExporterGolden::normalized($request->body)); + } + + #[Group('ac-06-25')] + public function testDatadogSuccessMatchesTheGoldenRequests(): void + { + $golden = ExporterGolden::requests('datadog ok'); + $this->assertCount(2, $golden); + $requests = DatadogPayload::of(new DatadogExporter('ddkey', 'https://datadoghq.eu/', 'svc', 'prod'))->requests(ExporterFixtures::event()); + + $this->assertSame([$golden[0]['url'], $golden[1]['url']], [$requests['logs']->url, $requests['metrics']->url]); + $this->assertSame(get_object_vars($golden[0]['headers']), $requests['logs']->headers); + $this->assertSame(get_object_vars($golden[1]['headers']), $requests['metrics']->headers); + $this->assertSame(ExporterGolden::normalized($golden[0]['body']), ExporterGolden::normalized($requests['logs']->body)); + $this->assertSame(ExporterGolden::normalized($golden[1]['body']), ExporterGolden::normalized($requests['metrics']->body)); + $this->assertSame(2, $requests['series']); + } + + #[Group('ac-06-26')] + public function testDatadogErrorMatchesTheGoldenRequests(): void + { + $golden = ExporterGolden::requests('datadog err'); + $this->assertCount(2, $golden); + $requests = DatadogPayload::of(new DatadogExporter('ddkey', 'datadoghq.com', 'svc'))->requests(ExporterFixtures::errorEvent()); + + $this->assertSame(ExporterGolden::normalized($golden[0]['body']), ExporterGolden::normalized($requests['logs']->body)); + $this->assertSame(ExporterGolden::normalized($golden[1]['body']), ExporterGolden::normalized($requests['metrics']->body)); + $this->assertSame(3, $requests['series']); + } + + #[Group('ac-06-29')] + public function testASessionlessOtlpEventMatchesTheGoldenRequestExceptTheRandomTraceId(): void + { + $golden = ExporterGolden::requests2('otlp')[0] ?? throw new \LogicException('golden request missing'); + $request = OtlpPayload::of(new OtlpExporter('http://c:4318'))->request(ExporterFixtures::listEvent()); + + $this->assertSame($golden['url'], $request->url); + $this->assertSame($this->withoutTrace($golden['body']), $this->withoutTrace($request->body)); + $this->assertSame(1, preg_match('/"traceId":"[0-9a-f]{32}"/', $request->body)); + } + + #[Group('ac-06-29')] + public function testASessionlessDatadogEventMatchesTheGoldenRequestsExceptTheRandomTraceId(): void + { + $golden = ExporterGolden::requests2('dd'); + $this->assertCount(2, $golden); + $requests = DatadogPayload::of(new DatadogExporter('ddkey', 'us3.datadoghq.com', 'svc'))->requests(ExporterFixtures::listEvent()); + + $this->assertSame([$golden[0]['url'], $golden[1]['url']], [$requests['logs']->url, $requests['metrics']->url]); + $this->assertSame($this->withoutTrace($golden[0]['body']), $this->withoutTrace($requests['logs']->body)); + $this->assertSame(ExporterGolden::normalized($golden[1]['body']), ExporterGolden::normalized($requests['metrics']->body)); + $this->assertSame(1, preg_match('/"trace_id":"[0-9]{1,20}"/', $requests['logs']->body)); + } + + #[Group('ac-06-31')] + #[Group('ac-06-34')] + public function testSentryLogMatchesTheGoldenEnvelope(): void + { + $golden = ExporterGolden::requests('sentry ok'); + $payload = SentryPayload::of(new SentryExporter('https://abc123@o1.ingest.sentry.io/42', 'production', '1.2.3')); + + $this->assertCount(1, $golden); + $this->assertSame($golden[0]['url'], $payload->endpoint); + $this->assertSame(get_object_vars($golden[0]['headers']), $payload->headers()); + $envelope = $payload->logEnvelope(ExporterFixtures::event(), '2026-10-17T00:00:00.000Z'); + $this->assertStringEndsWith("\n", $envelope); + $this->assertSame(ExporterGolden::maskSentry($golden[0]['body']), ExporterGolden::maskSentry($envelope)); + } + + #[Group('ac-06-32')] + #[Group('ac-06-35')] + public function testSentryErrorWithTracingMatchesTheGoldenEnvelopes(): void + { + $golden = ExporterGolden::requests('sentry err tracing'); + $payload = SentryPayload::of(new SentryExporter('https://abc123@sentry.example.com:9000/sub/path/42', enableTracing: true)); + $event = ExporterFixtures::errorEvent(); + $transaction = $payload->transaction($event); + $error = $payload->errorEvent($event, $payload->transaction($event)); + + $this->assertCount(3, $golden); + $this->assertSame('https://sentry.example.com:9000/sub/path/api/42/envelope/', $payload->endpoint); + $this->assertSame(ExporterGolden::maskSentry($golden[0]['body']), ExporterGolden::maskSentry($payload->logEnvelope($event, 'X'))); + $this->assertSame(ExporterGolden::maskSentry($golden[1]['body']), ExporterGolden::maskSentry($payload->transactionEnvelope($transaction, 'X'))); + $this->assertSame(ExporterGolden::maskSentry($golden[2]['body']), ExporterGolden::maskSentry($payload->errorEnvelope($error, 'X'))); + $this->assertStringEndsNotWith("\n", $payload->transactionEnvelope($transaction, 'X')); + $trace = JsonAssert::at(Json::decode(ExportJson::encode($error['body'])), 'contexts', 'trace'); + $this->assertSame(JsonAssert::at($trace, 'span_id'), JsonAssert::at($trace, 'parent_span_id'), 'self-parent quirk'); + $this->assertMatchesRegularExpression('/\A38fbea6b36e16f37[0-9a-f]{16}\z/', $transaction->eventId); + } + + #[Group('ac-06-29')] + public function testASessionlessSentryLogMatchesTheGoldenEnvelopeExceptTheRandomTraceId(): void + { + $golden = ExporterGolden::requests2('sentry')[0] ?? throw new \LogicException('golden request missing'); + $payload = SentryPayload::of(new SentryExporter('https://abc123@o1.ingest.sentry.io/42')); + $envelope = $payload->logEnvelope(ExporterFixtures::listEvent(), 'X'); + $mask = static fn(string $text): string => preg_replace('/"trace_id":"[0-9a-f]{32}"/', '"trace_id":"T"', ExporterGolden::maskSentry($text)) ?? ''; + + $this->assertSame($golden['url'], $payload->endpoint); + $this->assertSame(1, preg_match('/"trace_id":"[0-9a-f]{32}"/', $envelope)); + $this->assertSame($mask($golden['body']), $mask($envelope)); + } + + #[Group('ac-06-40')] + public function testPostHogSuccessWithAiTracingMatchesTheGoldenBatch(): void + { + $golden = ExporterGolden::requests('posthog ok ai')[0] ?? throw new \LogicException('golden request missing'); + $payload = PostHogPayload::of(new PostHogExporter('phc_x', enableAITracing: true)); + $request = $payload->request($payload->batch(ExporterFixtures::event())); + + $this->assertSame($golden['url'], $request->url); + $this->assertSame(get_object_vars($golden['headers']), $request->headers); + $this->assertSame(ExporterGolden::normalized($golden['body']), ExporterGolden::normalized($request->body)); + } + + #[Group('ac-06-41')] + public function testPostHogErrorWithAiTracingMatchesTheGoldenBatch(): void + { + $golden = ExporterGolden::requests('posthog err ai')[0] ?? throw new \LogicException('golden request missing'); + $payload = PostHogPayload::of(new PostHogExporter('phc_x', 'https://eu.i.posthog.com/', true)); + $request = $payload->request($payload->batch(ExporterFixtures::errorEvent())); + + $this->assertSame($golden['url'], $request->url); + $this->assertSame(ExporterGolden::normalized($golden['body']), ExporterGolden::normalized($request->body)); + } + + #[Group('ac-06-42')] + public function testPostHogCustomEventMatchesTheGoldenBatch(): void + { + $golden = ExporterGolden::requests('posthog custom')[0] ?? throw new \LogicException('golden request missing'); + $payload = PostHogPayload::of(new PostHogExporter('phc_x')); + $request = $payload->request($payload->batch(ExporterFixtures::customEvent())); + + $this->assertSame($golden['url'], $request->url); + $this->assertSame(ExporterGolden::normalized($golden['body']), ExporterGolden::normalized($request->body)); + } + + #[Group('ac-06-29')] + public function testASessionlessPostHogEventMatchesTheGoldenBatch(): void + { + $golden = ExporterGolden::requests2('posthog')[0] ?? throw new \LogicException('golden request missing'); + $payload = PostHogPayload::of(new PostHogExporter('phc')); + $request = $payload->request($payload->batch(ExporterFixtures::listEvent())); + + $this->assertSame($golden['url'], $request->url); + $this->assertSame(ExporterGolden::normalized($golden['body']), ExporterGolden::normalized($request->body)); + } + + private function withoutTrace(string $json): string + { + $tree = Json::decode($json); + $tree = self::replace($tree); + + return ExporterGolden::normalized(Json::encodeJs($tree) ?? ''); + } + + private static function replace(mixed $node): mixed + { + if (is_array($node)) { + return array_map(self::replace(...), $node); + } + if ($node instanceof \stdClass) { + foreach (get_object_vars($node) as $key => $child) { + $node->{$key} = ($key === 'traceId' || $key === 'trace_id') ? 'X' : self::replace($child); + } + } + + return $node; + } +} diff --git a/tests/Golden/ExporterIdsGoldenTest.php b/tests/Golden/ExporterIdsGoldenTest.php new file mode 100644 index 0000000..e48e411 --- /dev/null +++ b/tests/Golden/ExporterIdsGoldenTest.php @@ -0,0 +1,34 @@ +assertSame($ids['traceId'], TraceContext::traceId($ids['sessionId'])); + $this->assertSame($ids['spanId'], TraceContext::spanId($ids['eventId'])); + $this->assertSame($ids['ddTrace'], TraceContext::datadogTraceId($ids['sessionId'])); + $this->assertSame($ids['ddSpan'], TraceContext::datadogSpanId($ids['eventId'])); + $this->assertSame($ids['uuid(session)'], UuidV7::fromPrefixedId($ids['sessionId'])); + $this->assertSame($ids['uuid(event)'], UuidV7::fromPrefixedId($ids['eventId'])); + $this->assertSame($ids['uuid(my-customer-task-id)'], UuidV7::fromPrefixedId('my-customer-task-id')); + $this->assertSame($ids["uuid('')"], UuidV7::fromPrefixedId('')); + $this->assertSame($ids['uuid(ses_session45600000000000000000)'], UuidV7::fromPrefixedId('ses_session45600000000000000000')); + $this->assertSame($ids['uuid(abc_def_ghi)'], UuidV7::fromPrefixedId('abc_def_ghi')); + $this->assertSame($ids['uuid(27 dashes)'], UuidV7::fromPrefixedId('x_' . str_repeat('-', 27))); + } +} diff --git a/tests/Integration/ClientDiagnosticsTest.php b/tests/Integration/ClientDiagnosticsTest.php new file mode 100644 index 0000000..9b93cf7 --- /dev/null +++ b/tests/Integration/ClientDiagnosticsTest.php @@ -0,0 +1,233 @@ +environment = Environment::clear('AGENTCAT_DISABLE_DIAGNOSTICS', 'AGENTCAT_DIAGNOSTICS_ENDPOINT', 'AGENTCAT_DIAGNOSTICS_TOKEN', 'DISABLE_DIAGNOSTICS', 'DIAGNOSTICS_ENDPOINT', 'DIAGNOSTICS_TOKEN', 'AGENTCAT_PROJECT_ID', 'AGENTCAT_API_URL', 'MCPCAT_API_URL', 'AGENTCAT_ENABLED', 'APP_ENV'); + $this->endpoint = MockCollector::closedPortUrl(); + putenv('AGENTCAT_DIAGNOSTICS_ENDPOINT=' . $this->endpoint); // fail closed: never otel.agentcat.com + putenv('DIAGNOSTICS_ENDPOINT=' . MockCollector::closedPortUrl()); // an application's own variable: never read (D50) + Diagnostics::reset(); + TrackedServers::resetIncomplete(); + TrackedServers::resetSkipped(); + $this->logger = new MemoryLogger(); + Log::setLogger($this->logger); + } + + #[\Override] + protected function tearDown(): void + { + Diagnostics::reset(); + TrackedServers::resetIncomplete(); + TrackedServers::resetSkipped(); + EventQueue::setGlobal(null); + Log::reset(); + $this->environment->restore(); + } + + #[Group('ac-06-47')] + #[Group('ac-01-60')] + public function testTrackSetsDiagnosticsUpBeforeTheCompatibilityCheck(): void + { + putenv('AGENTCAT_DISABLE_DIAGNOSTICS=false'); // PHPUnit is a test environment: force it on, as a customer CI could + $http = new RecordingHttpChannel(); + $queue = new EventQueue(transport: new RecordingTransport(), channel: $http); + $client = new Client(ClientSeams::of($queue, static fn(): bool => true)); + + $client->track(new \stdClass(), self::PROJECT); // an unsupported object: TS still sets diagnostics up first + $queue->shutdown(); + + $this->assertTrue(Diagnostics::enabled()); + $bodies = $this->recordBodies($http); + $this->assertCount(2, $bodies); + $this->assertStringContainsString('AgentCat SDK Compatibility: unrecognized server shape', $bodies[0]); + $this->assertStringContainsString('Warning: Failed to track server - ', $bodies[1]); + } + + #[Group('ac-06-52')] + #[Group('ac-01-65')] + public function testATestEnvironmentKeepsDiagnosticsOffByDefault(): void + { + $client = new Client(ClientSeams::of(new EventQueue(transport: new RecordingTransport(), channel: new RecordingHttpChannel()), static fn(): bool => true)); + $client->track(TrackingFixture::builder(), self::PROJECT); + + $this->assertFalse(Diagnostics::enabled()); + } + + #[Group('ac-06-53')] + #[Group('ac-01-11')] + public function testAFirstTrackWithDiagnosticsDisabledKeepsThemOff(): void + { + putenv('AGENTCAT_DISABLE_DIAGNOSTICS=false'); + $client = $this->client(new RecordingHttpChannel()); + + $client->track(TrackingFixture::builder(), self::PROJECT, new Options(disableDiagnostics: true)); + $client->track(TrackingFixture::builder(), self::PROJECT); + + $this->assertFalse(Diagnostics::enabled()); + $this->assertNotContains(self::IGNORED, $this->logger->messages()); + } + + #[Group('ac-06-53')] + #[Group('ac-01-11')] + public function testALaterTrackCannotDisableDiagnosticsAndWarnsOnce(): void + { + putenv('AGENTCAT_DISABLE_DIAGNOSTICS=false'); + $client = $this->client(new RecordingHttpChannel()); + + $client->track(TrackingFixture::builder(), self::PROJECT); + $client->track(TrackingFixture::builder(), self::PROJECT, new Options(disableDiagnostics: true)); + $client->track(TrackingFixture::builder(), self::PROJECT, new Options(disableDiagnostics: true)); + + $this->assertTrue(Diagnostics::enabled()); + $this->assertCount(1, array_keys($this->logger->messages(), self::IGNORED, true)); + } + + /** D44 and spec conflict 9: the enabled gate runs before the diagnostics setup. */ + public function testADisabledClientNeverSetsDiagnosticsUp(): void + { + putenv('AGENTCAT_DISABLE_DIAGNOSTICS=false'); + $client = new Client(ClientSeams::of(new EventQueue(transport: new RecordingTransport(), channel: new RecordingHttpChannel()), static fn(): bool => false)); + + $client->track(TrackingFixture::builder(), self::PROJECT); + + $this->assertFalse(Diagnostics::enabled()); + } + + /** `AgentCat::fake()`'s client: no telemetry, so no diagnostics either. */ + public function testWithoutTheTelemetrySeamNoDiagnosticsAreSetUp(): void + { + putenv('AGENTCAT_DISABLE_DIAGNOSTICS=false'); + $client = new Client(ClientSeams::of(new EventQueue(transport: new RecordingTransport(), channel: new RecordingHttpChannel()), static fn(): bool => true, telemetry: false)); + + $client->track(TrackingFixture::builder(), self::PROJECT); + + $this->assertFalse(Diagnostics::enabled()); + } + + /** D07 over 06 AC-61 (spec conflict 2): the per-event line carries the actor ID; no other line carries user data. */ + #[Group('ac-06-61')] + #[MaximumDuration(5000)] + public function testOnlyTheSentEventLineCarriesTheActorId(): void + { + putenv('AGENTCAT_DISABLE_DIAGNOSTICS=false'); + $collector = MockCollector::start(); + + try { + $http = new RecordingHttpChannel(); + $queue = new EventQueue(transport: new CurlTransport(new RetryPolicy([0.01, 0.02, 0.04])), channel: $http); + $client = new Client(ClientSeams::of($queue, static fn(): bool => true)); + $builder = TrackingFixture::builder(); + Tools::echo($builder); + $client->track($builder, self::PROJECT, new Options( + identify: static fn(): Identity => new Identity('user-42@example.com', 'Ada Secret', ['plan' => 'top-secret-plan']), + apiBaseUrl: $collector->scenario(), + )); + $harness = new HttpHarness($builder->build()); + $harness->initialize(); + $harness->callTool('echo', ['msg' => 'London-weather-arg', 'context' => 'context-text-why', 'session_id' => 'start']); + + $queue->shutdown(); + + $lines = $this->recordBodies($http); + $sent = array_values(array_filter($lines, static fn(string $line): bool => str_contains($line, 'Successfully sent event '))); + $this->assertCount(1, $sent); + $this->assertStringEndsWith(' ms | user-42@example.com', $sent[0]); + foreach ($lines as $line) { + foreach (['Ada Secret', 'top-secret-plan', 'London-weather-arg', 'context-text-why'] as $private) { + $this->assertStringNotContainsString($private, $line); + } + if ($line !== $sent[0]) { + $this->assertStringNotContainsString('user-42@example.com', $line); + } + } + } finally { + $collector->stop(); + } + } + + /** The shutdown posts the buffer once, and a request that never completes cannot hold it past the budget. */ + #[Group('ac-06-62')] + public function testShutdownSendsTheDiagnosticsOnceWithinItsBudget(): void + { + putenv('AGENTCAT_DISABLE_DIAGNOSTICS=false'); + $transport = new RecordingTransport(); // its clock is simulated: waits jump ahead, nothing sleeps + $http = new RecordingHttpChannel(complete: false); + $queue = new EventQueue(transport: $transport, shutdownTimeoutSeconds: 1.0, channel: $http); + $client = new Client(ClientSeams::of($queue, static fn(): bool => true)); + $client->track(TrackingFixture::builder(), self::PROJECT); + + $queue->shutdown(); + + $this->assertTrue(Diagnostics::enabled()); + $this->assertSame([$this->endpoint . '/v1/logs'], $http->urls()); + $this->assertLessThanOrEqual(1.0, $transport->now(), 'the shutdown ended at its 1 s deadline'); + } + + private function client(RecordingHttpChannel $http): Client + { + return new Client(ClientSeams::of(new EventQueue(transport: new RecordingTransport(), channel: $http), static fn(): bool => true)); + } + + /** @return list the record bodies of every diagnostics request, in order */ + private function recordBodies(RecordingHttpChannel $http): array + { + $lines = []; + foreach ($http->requests as $request) { + if (!str_ends_with($request->url, '/v1/logs')) { + continue; + } + $records = JsonAssert::at(Json::decode($request->body), 'resourceLogs', 0, 'scopeLogs', 0, 'logRecords'); + $this->assertIsList($records); + foreach ($records as $record) { + $text = JsonAssert::at($record, 'body', 'stringValue'); + $this->assertIsString($text); + $lines[] = $text; + } + } + + return $lines; + } +} diff --git a/tests/Integration/ClientExportersTest.php b/tests/Integration/ClientExportersTest.php new file mode 100644 index 0000000..8195b9a --- /dev/null +++ b/tests/Integration/ClientExportersTest.php @@ -0,0 +1,291 @@ +environment = Environment::clear('AGENTCAT_PROJECT_ID', 'AGENTCAT_API_URL', 'MCPCAT_API_URL', 'AGENTCAT_ENABLED', 'APP_ENV'); + TrackedServers::resetIncomplete(); + TrackedServers::resetSkipped(); + $this->logger = new MemoryLogger(); + Log::setLogger($this->logger); + $this->transport = new RecordingTransport(); + $this->http = new RecordingHttpChannel(); + $this->queue = new EventQueue(transport: $this->transport, channel: $this->http); + $this->client = new Client(ClientSeams::of($this->queue, static fn(): bool => true)); + } + + #[\Override] + protected function tearDown(): void + { + TrackedServers::resetIncomplete(); + TrackedServers::resetSkipped(); + EventQueue::setGlobal(null); + Log::reset(); + $this->environment->restore(); + } + + #[Group('ac-06-14')] + #[Group('ac-01-5')] + public function testTelemetryOnlyTrackingExportsAndNeverCallsTheApi(): void + { + $builder = TrackingFixture::builder(); + Tools::echo($builder); + $this->client->track($builder, '', new Options(exporters: ['otlp' => new OtlpExporter('http://otlp.test')])); + $this->call($builder); + + $this->assertTrue($this->queue->flush()); + + $this->assertSame([], $this->transport->deliveries); + $this->assertSame(['http://otlp.test/v1/traces'], $this->http->urls()); + $this->assertNotContains(self::NO_PROJECT, $this->logger->messages()); + $this->assertContains('AgentCat setup complete | project (telemetry-only) | tracing=true context=true reportMissing=true exporters=1', $this->logger->messages()); + $this->assertSame(['Initialized telemetry exporter: otlp', 'Initialized telemetry with 1 exporters'], $this->initializedLines()); + } + + #[Group('ac-06-13')] + #[Group('ac-01-10')] + public function testOnlyTheFirstExportersReceiveEvents(): void + { + $first = TrackingFixture::builder(); + $second = TrackingFixture::builder(); + Tools::echo($first); + Tools::echo($second); + $this->client->track($first, self::PROJECT, new Options(exporters: ['a' => new OtlpExporter('http://a.test')])); + $this->client->track($second, self::PROJECT, new Options(exporters: ['b' => new OtlpExporter('http://b.test')])); + $this->call($first); + $this->call($second); + + $this->assertTrue($this->queue->flush()); + + $this->assertSame(['http://a.test/v1/traces', 'http://a.test/v1/traces'], $this->http->urls()); + $this->assertContains('WARN: AgentCat exporters were set up by an earlier track(); the exporters of this call are ignored', $this->logger->messages()); + $this->assertContains('AgentCat setup complete | project ' . self::PROJECT . ' | tracing=true context=true reportMissing=true exporters=1', $this->logger->messages()); + } + + /** Review Focus 3: a new Builder per request in a long-running worker, the same exporters every time. */ + public function testPerRequestBuildersWithTheSameExportersShareOneManager(): void + { + for ($request = 0; $request < 3; ++$request) { + $builder = TrackingFixture::builder(); + Tools::echo($builder); + $this->client->track($builder, self::PROJECT, new Options(exporters: ['otlp' => new OtlpExporter('http://otlp.test')])); + $this->call($builder); + } + + $this->assertTrue($this->queue->flush()); + + $this->assertCount(3, $this->http->requests); + $this->assertCount(1, array_keys($this->logger->messages(), 'Initialized telemetry with 1 exporters', true)); + $this->assertSame([], array_values(array_filter($this->logger->messages(), static fn(string $l): bool => str_starts_with($l, 'WARN: AgentCat exporters')))); + } + + #[Group('ac-06-16')] + public function testPostHogGetsTheRedactedParameters(): void + { + $builder = TrackingFixture::builder(); + Tools::echo($builder); + $this->client->track($builder, self::PROJECT, new Options( + redactSensitiveInformation: static fn(string $text): string => $text === 'secret' ? '[R]' : $text, + exporters: ['ph' => new PostHogExporter('phc_x')], + )); + $this->call($builder, ['msg' => 'secret']); + + $this->assertTrue($this->queue->flush()); + + $request = $this->http->requests[0] ?? null; + $this->assertInstanceOf(\AgentCat\Internal\Transport\HttpRequest::class, $request); + $body = Json::decode($request->body); + $this->assertSame('[R]', JsonAssert::at($body, 'batch', 0, 'properties', 'parameters', 'request', 'params', 'arguments', 'msg')); + } + + #[Group('ac-06-15')] + public function testAnEventDroppedByRedactEventIsNotExported(): void + { + $builder = TrackingFixture::builder(); + Tools::echo($builder); + $this->client->track($builder, self::PROJECT, new Options( + redactEvent: static fn(Event $event): ?Event => null, + exporters: ['otlp' => new OtlpExporter('http://otlp.test')], + )); + $this->call($builder); + + $this->assertTrue($this->queue->flush()); + + $this->assertSame([], $this->http->requests); + $this->assertSame([], $this->transport->deliveries); + } + + public function testEmptyExportersKeepTheTypeScriptEmptyObjectSemantics(): void + { + $builder = TrackingFixture::builder(); + Tools::echo($builder); + $this->client->track($builder, '', new Options(exporters: [])); + + $this->assertNotContains(self::NO_PROJECT, $this->logger->messages()); + $this->assertSame(['Initialized telemetry with 0 exporters'], $this->initializedLines()); + $this->assertContains('AgentCat setup complete | project (telemetry-only) | tracing=true context=true reportMissing=true exporters=0', $this->logger->messages()); + } + + public function testNullExportersKeepTheNoProjectWarningAndSetNothingUp(): void + { + $builder = TrackingFixture::builder(); + Tools::echo($builder); + $this->client->track($builder, ''); + + $this->assertContains(self::NO_PROJECT, $this->logger->messages()); + $this->assertSame([], $this->initializedLines()); + } + + public function testASecondTrackOfATrackedServerNeverTouchesTheExporters(): void + { + $builder = TrackingFixture::builder(); + Tools::echo($builder); + $this->client->track($builder, self::PROJECT); + $this->client->track($builder, self::PROJECT, new Options(exporters: ['otlp' => new OtlpExporter('http://otlp.test')])); + + $this->assertSame([], $this->initializedLines()); + $this->assertSame([], array_values(array_filter($this->logger->messages(), static fn(string $l): bool => str_starts_with($l, 'WARN: AgentCat exporters')))); + $this->call($builder); + $this->assertTrue($this->queue->flush()); + $this->assertSame([], $this->http->requests); + } + + public function testASecondTrackOfATrackedServerWithOtherExportersLogsNoWarning(): void + { + $builder = TrackingFixture::builder(); + Tools::echo($builder); + $this->client->track($builder, self::PROJECT, new Options(exporters: ['a' => new OtlpExporter('http://a.test')])); + $this->client->track($builder, self::PROJECT, new Options(exporters: ['b' => new DatadogExporter('k', 'datadoghq.com', 'svc')])); + + $this->assertSame(['Initialized telemetry exporter: a', 'Initialized telemetry with 1 exporters'], $this->initializedLines()); + $this->assertSame([], array_values(array_filter($this->logger->messages(), static fn(string $l): bool => str_starts_with($l, 'WARN: AgentCat exporters')))); + } + + /** B03 parity, D15: the exports run once per API attempt, so three failed attempts give four exports. */ + #[Group('ac-06-17')] + #[MaximumDuration(5000)] + public function testExportsRunOncePerApiAttempt(): void + { + $collector = MockCollector::start(); + + try { + $base = $collector->scenario(array_fill(0, 4, ['status' => 503])); + $queue = new EventQueue(transport: new CurlTransport(new RetryPolicy([0.01, 0.02, 0.04])), channel: $this->http); + $client = new Client(ClientSeams::of($queue, static fn(): bool => true)); + $builder = TrackingFixture::builder(); + Tools::echo($builder); + $client->track($builder, self::PROJECT, new Options(apiBaseUrl: $base, exporters: ['otlp' => new OtlpExporter('http://otlp.test')])); + $this->call($builder); + + $this->assertTrue($client->flush(5.0)); + + $this->assertCount(4, $collector->requests($base)); + $this->assertCount(4, $this->http->requests); + $spanIds = array_map( + static fn(\AgentCat\Internal\Transport\HttpRequest $request): mixed => JsonAssert::at(Json::decode($request->body), 'resourceSpans', 0, 'scopeSpans', 0, 'spans', 0, 'spanId'), + $this->http->requests, + ); + $this->assertIsString($spanIds[0]); + $this->assertSame(array_fill(0, 4, $spanIds[0]), $spanIds); + $retries = array_filter($this->logger->messages(), static fn(string $l): bool => str_starts_with($l, 'Failed to send event ') && str_contains($l, ', retrying... [Error: HTTP 503]')); + $this->assertCount(4, $retries); + } finally { + $collector->stop(); + } + } + + /** A `Client` built without seams (what a container autowires) sets exporters up on the process queue. */ + public function testTheDefaultClientSetsExportersUpOnTheGlobalQueue(): void + { + putenv('AGENTCAT_ENABLED=true'); + EventQueue::setGlobal($this->queue); + $builder = TrackingFixture::builder(); + Tools::echo($builder); + (new Client())->track($builder, self::PROJECT, new Options(exporters: ['otlp' => new OtlpExporter('http://otlp.test')])); + $this->call($builder); + + $this->assertTrue($this->queue->flush()); + + $this->assertSame(['http://otlp.test/v1/traces'], $this->http->urls()); + $this->assertContains('Initialized telemetry with 1 exporters', $this->logger->messages()); + } + + public function testWithoutTheTelemetrySeamNoExportersAreSetUp(): void + { + $client = new Client(ClientSeams::of($this->queue, static fn(): bool => true, telemetry: false)); + $builder = TrackingFixture::builder(); + Tools::echo($builder); + $client->track($builder, '', new Options(exporters: ['otlp' => new OtlpExporter('http://otlp.test')])); + $this->call($builder); + + $this->assertTrue($this->queue->flush()); + + $this->assertSame([], $this->initializedLines()); + $this->assertSame([], $this->http->requests); + $this->assertContains('AgentCat setup complete | project (telemetry-only) | tracing=true context=true reportMissing=true exporters=1', $this->logger->messages()); + } + + /** @return list */ + private function initializedLines(): array + { + return array_values(array_filter($this->logger->messages(), static fn(string $l): bool => str_starts_with($l, 'Initialized'))); + } + + /** @param array $arguments */ + private function call(Builder $builder, array $arguments = ['msg' => 'hi']): void + { + $harness = new HttpHarness($builder->build()); // not TrackingFixture: its constructor replaces the logger + $harness->initialize(); + $harness->callTool('echo', $arguments + ['context' => 'why', 'session_id' => 'start']); + } +} diff --git a/tests/Integration/ClientTrackTest.php b/tests/Integration/ClientTrackTest.php index 9d8f3c4..9a540de 100644 --- a/tests/Integration/ClientTrackTest.php +++ b/tests/Integration/ClientTrackTest.php @@ -6,10 +6,13 @@ use AgentCat\Client; use AgentCat\Event; +use AgentCat\Exporters\OtlpExporter; +use AgentCat\Exporters\PostHogExporter; use AgentCat\Identity; use AgentCat\Internal\ClientSeams; use AgentCat\Internal\Constants; use AgentCat\Internal\Delivery\EventQueue; +use AgentCat\Internal\Delivery\SignalDrain; use AgentCat\Internal\Engine\TrackedServers; use AgentCat\Internal\Json\Json; use AgentCat\Internal\Logging\Log; @@ -28,6 +31,7 @@ use AgentCat\Tests\Support\McpSdk\TrackingFixture; use AgentCat\Tests\Support\MemoryLogger; use AgentCat\Tests\Support\RecordingExporter; +use AgentCat\Tests\Support\RecordingHttpChannel; use AgentCat\Tests\Support\RecordingTransport; use AgentCat\ToolCall; use Ergebnis\PHPUnit\SlowTestDetector\Attribute\MaximumDuration; @@ -35,6 +39,7 @@ use Mcp\Server\Builder; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\RequiresPhpExtension; use PHPUnit\Framework\TestCase; use Psr\Log\AbstractLogger; use Psr\Log\LogLevel; @@ -50,6 +55,8 @@ final class ClientTrackTest extends TestCase private const string ALREADY_TRACKED = '[SESSION DEBUG] track() - Server already being tracked, skipping initialization'; + private const string SIGNALS_INSTALLED = 'WARN: AgentCat signal handlers are already installed; disableSignalHandlers is ignored'; + private const string REBUILT = 'Rebuilt injection registries on demand (tools/call before tools/list on this instance)'; private Environment $environment; @@ -64,9 +71,20 @@ final class ClientTrackTest extends TestCase private Client $client; + /** @var array the SIGTERM and SIGINT handlers before the test */ + private array $savedHandlers = []; + + private ?bool $savedAsyncSignals = null; + #[\Override] protected function setUp(): void { + if (function_exists('pcntl_signal_get_handler')) { + foreach ([\SIGTERM, \SIGINT] as $signal) { + $this->savedHandlers[$signal] = pcntl_signal_get_handler($signal); + } + $this->savedAsyncSignals = pcntl_async_signals(); + } $this->environment = Environment::clear('AGENTCAT_PROJECT_ID', 'AGENTCAT_API_URL', 'MCPCAT_API_URL', 'AGENTCAT_ENABLED', 'APP_ENV'); TrackedServers::resetIncomplete(); TrackedServers::resetSkipped(); @@ -81,12 +99,47 @@ protected function setUp(): void #[\Override] protected function tearDown(): void { + foreach ($this->savedHandlers as $signal => $handler) { + pcntl_signal($signal, $handler); + } + if ($this->savedAsyncSignals !== null) { + pcntl_async_signals($this->savedAsyncSignals); + } TrackedServers::resetIncomplete(); // a failed track() marks the process TrackedServers::resetSkipped(); // so does a track() while AgentCat is off Log::reset(); $this->environment->restore(); } + #[RequiresPhpExtension('pcntl')] + public function testTrackWarnsOncePerProcessWhenTheSignalHandlersAreInstalledAlready(): void + { + $queue = new EventQueue(transport: $this->transport, signalDrain: $this->installedDrain()); + $client = new Client(ClientSeams::of($queue, static fn(): bool => true)); + + $client->track(TrackingFixture::builder(), self::PROJECT, new Options(disableSignalHandlers: true)); + $client->track(TrackingFixture::builder(), self::PROJECT, new Options(disableSignalHandlers: true)); + $client->track(TrackingFixture::builder(), self::PROJECT); + + $this->assertCount(1, array_keys($this->logger->messages(), self::SIGNALS_INSTALLED, true)); + } + + #[RequiresPhpExtension('pcntl')] + public function testTrackSaysNothingWhenTheSwitchIsOffOrThereIsNothingToDisable(): void + { + $term = pcntl_signal_get_handler(\SIGTERM); + $drain = new SignalDrain(fn(): EventQueue => $this->queue, null, static fn(string $f): bool => true, 'cli'); + $client = new Client(ClientSeams::of(new EventQueue(transport: $this->transport, signalDrain: $drain), static fn(): bool => true)); + + $client->track(TrackingFixture::builder(), self::PROJECT, new Options(disableSignalHandlers: true)); + $drain->install(); + + $this->assertNotContains(self::SIGNALS_INSTALLED, $this->logger->messages()); + $this->assertSame($term, pcntl_signal_get_handler(\SIGTERM), 'the switch kept the drain out'); + $this->client->track(TrackingFixture::builder(), self::PROJECT, new Options(disableSignalHandlers: true)); // a queue without a drain + $this->assertNotContains(self::SIGNALS_INSTALLED, $this->logger->messages()); + } + #[Group('ac-01-1')] #[Group('ac-02-1')] #[Group('ac-01-4')] @@ -105,6 +158,25 @@ public function testTrackInstrumentsTheBuilderAndReturnsIt(): void $this->assertFalse(TrackedServers::incomplete()); } + #[Group('ac-06-14')] + public function testTheCompleteLineCountsTheExporters(): void + { + $queue = new EventQueue(transport: $this->transport, channel: new RecordingHttpChannel()); + $client = new Client(ClientSeams::of($queue, static fn(): bool => true)); + $builder = TrackingFixture::builder(); + Tools::echo($builder); + + $client->track($builder, self::PROJECT, new Options(exporters: [ + 'otlp' => new OtlpExporter('http://otlp.test'), + 'ph' => new PostHogExporter('phc_x'), + ])); + + $this->assertContains( + 'AgentCat setup complete | project ' . self::PROJECT . ' | tracing=true context=true reportMissing=true exporters=2', + $this->logger->messages(), + ); + } + #[Group('ac-01-4')] public function testTheCompleteLineShowsTheOptions(): void { @@ -589,4 +661,12 @@ private function urls(): array { return array_map(static fn(Delivery $delivery): string => $delivery->url, $this->transport->deliveries); } + + private function installedDrain(): SignalDrain + { + $drain = new SignalDrain(fn(): EventQueue => $this->queue, null, static fn(string $f): bool => true, 'cli'); + $drain->install(); + + return $drain; + } } diff --git a/tests/Integration/Delivery/EventQueueDeliveryTest.php b/tests/Integration/Delivery/EventQueueDeliveryTest.php index 2ddc8c8..bbfeb5f 100644 --- a/tests/Integration/Delivery/EventQueueDeliveryTest.php +++ b/tests/Integration/Delivery/EventQueueDeliveryTest.php @@ -12,7 +12,11 @@ use AgentCat\Internal\Logging\Log; use AgentCat\Internal\Pipeline\EventProcessor; use AgentCat\Internal\Pipeline\QueuedEvent; +use AgentCat\Internal\Transport\CurlHttpChannel; use AgentCat\Internal\Transport\CurlTransport; +use AgentCat\Internal\Transport\HttpChannel; +use AgentCat\Internal\Transport\HttpRequest; +use AgentCat\Internal\Transport\HttpResponse; use AgentCat\Internal\Transport\RetryPolicy; use AgentCat\Tests\Integration\Support\MockCollector; use AgentCat\Tests\Support\JsonAssert; @@ -149,6 +153,90 @@ public function testShutdownStopsWaitingAtItsBudget(): void $this->assertEqualsWithDelta(5.0, EventQueue::SHUTDOWN_TIMEOUT_SECONDS, PHP_FLOAT_EPSILON); } + /** + * Review Focus 1: an exporter endpoint that hangs, on a server whose AgentCat API works. `pump()` returns at once, + * the event is delivered, and `shutdown()` waits for the export only until its budget, then abandons it. + */ + #[Group('ac-06-63')] + #[MaximumDuration(5000)] + public function testASlowExportEndpointNeverHoldsTheEventsBack(): void + { + $events = $this->collector()->scenario(); + $slow = $this->collector()->scenario([], ['status' => 200, 'delayMs' => 3000]); + $channel = new CurlHttpChannel(); + $exporter = new readonly class ($channel, $slow) implements ExporterSink { + public function __construct(private HttpChannel $channel, private string $url) {} + + #[\Override] + public function export(Event $event): void + { + $this->channel->post(new HttpRequest($this->url . '/v1/traces', [], '{}'), static function (): void {}); + } + }; + $queue = new EventQueue(new EventProcessor(), new CurlTransport(new RetryPolicy([0.01, 0.02, 0.04])), $exporter, null, 0.3, null, $channel); + $queue->configure($events); + $queue->add(new QueuedEvent($this->event('slow-export'))); + + $start = hrtime(true); + $queue->pump(); + $this->assertLessThan(1.0, (hrtime(true) - $start) / 1e9, 'pump() at handler entry never waits'); + + $start = hrtime(true); + $queue->shutdown(); + $elapsed = (hrtime(true) - $start) / 1e9; + + $this->assertGreaterThanOrEqual(0.29, $elapsed, 'shutdown() waits for the export, up to its budget'); + $this->assertLessThan(1.5, $elapsed, 'the shutdown budget (0.3 s here) bounds the slow export'); + $this->assertCount(1, $this->collector()->requests($events), 'the event is delivered'); + $this->assertSame(1, $channel->pending(), 'the slow export is abandoned'); + } + + /** + * I2: a SIGTERM drain that runs inside a callback of the side channel (the seam stands in for the signal + * handler). It delivers the event on the transport's own multi handle and returns once the event is delivered: the + * side channel cannot progress inside its own callback, so the drain does not wait for it. The side transfer then + * completes in the next passes. + */ + #[MaximumDuration(3000)] + public function testADrainInsideASideTransferCallbackDeliversTheEventAndReturns(): void + { + $events = $this->collector()->scenario(); + $side = $this->collector()->scenario([], ['status' => 200, 'body' => str_repeat('x', 200_000)]); + $state = new class { + public ?EventQueue $queue = null; + + /** Seconds the drain took; null until it ran. */ + public ?float $drained = null; + }; + $channel = new CurlHttpChannel(onCallback: function () use ($state): void { + if ($state->drained !== null || !$state->queue instanceof EventQueue) { + return; + } + $state->queue->add(new QueuedEvent($this->event('in-callback'))); + $begin = hrtime(true); + $state->queue->finalShutdown(); + $state->drained = (hrtime(true) - $begin) / 1e9; + }); + $state->queue = new EventQueue(new EventProcessor(), new CurlTransport(new RetryPolicy([0.01, 0.02, 0.04])), new NullExporterSink(), null, EventQueue::SHUTDOWN_TIMEOUT_SECONDS, null, $channel); + $state->queue->configure($events); + $outcome = null; + $channel->post(new HttpRequest($side . '/v1/traces', [], '{}'), static function (HttpResponse $response) use (&$outcome): void { + $outcome = [$response->status, $response->error]; + }); + + $until = microtime(true) + 2.0; + do { + $this->assertLessThan($until, microtime(true), 'the side transfer did not finish'); + $channel->pump(); + $channel->waitForActivity(0.05); + } while ($channel->pending() > 0); + + $this->assertIsFloat($state->drained, 'the drain ran inside a write callback'); + $this->assertLessThan(2.5, $state->drained, 'it did not wait for the side channel it interrupted (the budget is 5 s)'); + $this->assertCount(1, $this->collector()->requests($events), "the event was delivered on the transport's own handle"); + $this->assertSame([200, null], $outcome, 'the side transfer completed'); + } + private function collector(): MockCollector { $this->assertInstanceOf(MockCollector::class, self::$collector); diff --git a/tests/Integration/Delivery/SignalAfterExecTest.php b/tests/Integration/Delivery/SignalAfterExecTest.php new file mode 100644 index 0000000..6711314 --- /dev/null +++ b/tests/Integration/Delivery/SignalAfterExecTest.php @@ -0,0 +1,155 @@ +stop(); + self::$collector = null; + } + + #[\Override] + protected function setUp(): void + { + $this->dir = getenv('HOME') . '/signal-after-exec-' . bin2hex(random_bytes(6)); + $this->assertTrue(mkdir($this->dir, 0o700)); + } + + #[\Override] + protected function tearDown(): void + { + $names = scandir($this->dir); + foreach ($names === false ? [] : $names as $name) { + if (is_file($this->dir . '/' . $name)) { + unlink($this->dir . '/' . $name); + } + } + rmdir($this->dir); + } + + /** @return iterable */ + public static function callers(): iterable + { + yield 'the event transport was in curl_multi_exec()' => ['transport']; + yield 'the side channel was in curl_multi_exec()' => ['channel']; + } + + /** Measured locally: the process ends about 0.01-0.1 s after SIGTERM; before the fix it lived 5 s and lost the event. */ + #[DataProvider('callers')] + #[MaximumDuration(15000)] + public function testADrainRightAfterCurlMultiExecDeliversTheEventAndItsExport(string $mode): void + { + $large = $this->collector()->scenario([], ['status' => 202, 'body' => str_repeat('x', 8_000_000)]); + $events = $this->collector()->scenario(); + $exports = $this->collector()->scenario(); + $process = new Process( + [PHP_BINARY, __DIR__ . '/../Fixtures/signal-after-exec.php', $mode, $large, $events, $exports], + null, + ['HOME' => $this->dir, 'AGENTCAT_DIAGNOSTICS_ENDPOINT' => MockCollector::closedPortUrl()], + ); + $process->setTimeout(40); + + try { + $process->run(); + } catch (ProcessSignaledException) { + // expected: SIGTERM with the default disposition ends the fixture after the drain + } + $ended = microtime(true); + + $this->assertSame('', $process->getOutput(), 'the fixture ran out of rounds before the probe found the moment'); + $this->assertTrue($process->hasBeenSignaled(), $process->getErrorOutput()); + $this->assertSame(15, $process->getTermSignal()); + $this->assertSame(1, preg_match('/^sigterm (\d+\.\d+)$/m', $process->getErrorOutput(), $match), $process->getErrorOutput()); + $this->assertLessThan(self::LIFETIME_AFTER_SIGTERM, $ended - (float) $match[1], 'the drain did not spend its 5 s budget waiting'); + $this->assertContains('drain', $this->resourceNames($events), 'the queued event went out on the event transport'); + $this->assertContains('/drain', array_map(static fn(\stdClass $request): mixed => $request->path, $this->collector()->requests($exports)), 'and its export on the side channel'); + } + + /** + * I1: an application's own handler that throws right after curl_multi_exec() returns (five times per process). On + * PHP 8.3 this corrupted the frame while the exec call was nested in another call's arguments: before the fix the + * fixture crashed in most runs (see the report). Now the server goes on, delivers every event and exits 0. + */ + #[MaximumDuration(15000)] + public function testAHandlerThatThrowsRightAfterCurlMultiExecLeavesTheServerRunning(): void + { + $large = $this->collector()->scenario([], ['status' => 202, 'body' => str_repeat('x', 4_000_000)]); + $process = new Process( + [PHP_BINARY, __DIR__ . '/../Fixtures/throw-after-exec.php', $large, '5'], + null, + ['HOME' => $this->dir, 'AGENTCAT_DIAGNOSTICS_ENDPOINT' => MockCollector::closedPortUrl()], + ); + $process->setTimeout(30); + + try { + $process->run(); + } catch (ProcessSignaledException $e) { + self::fail('the fixture crashed: signal ' . $e->getSignal() . ' ' . $process->getErrorOutput()); + } + + $this->assertSame(0, $process->getExitCode(), $process->getOutput() . $process->getErrorOutput()); + $this->assertSame('', $process->getErrorOutput()); + $this->assertSame(1, preg_match('/\Athrows=(\d+) left=0\n\z/', $process->getOutput(), $match), $process->getOutput()); + $throws = (int) $match[1]; + $this->assertGreaterThan(0, $throws, 'the probe found the moment'); + $names = $this->resourceNames($large); + for ($i = 1; $i <= $throws; ++$i) { + $this->assertContains('thrown-' . $i, $names, 'the event queued by the throwing handler went out'); + } + } + + /** @return list the `resource_name` of each event the scenario received */ + private function resourceNames(string $base): array + { + $names = []; + foreach ($this->collector()->requests($base) as $request) { + $body = $request->body; + $this->assertIsString($body); + $names[] = JsonAssert::at(Json::decode($body), 'resource_name'); + } + + return $names; + } + + private function collector(): MockCollector + { + $this->assertInstanceOf(MockCollector::class, self::$collector); + + return self::$collector; + } +} diff --git a/tests/Integration/Delivery/SignalDrainDeliveryTest.php b/tests/Integration/Delivery/SignalDrainDeliveryTest.php index 270ffcd..d896a21 100644 --- a/tests/Integration/Delivery/SignalDrainDeliveryTest.php +++ b/tests/Integration/Delivery/SignalDrainDeliveryTest.php @@ -91,6 +91,43 @@ public function testWithoutAHandlerTheQueueDrainsThenSigtermEndsTheProcess(): vo $this->assertSame(['signal-drain'], $this->resourceNames($base)); } + #[MaximumDuration(10000)] + public function testWithTheHandlersDisabledSigtermEndsTheProcessAtOnceAndNothingIsSent(): void + { + $base = $this->collector()->scenario(); + $process = $this->fixture($base, 'disabled'); + + $signaled = null; + $started = microtime(true); + + try { + $process->run(); + } catch (ProcessSignaledException $e) { + $signaled = $e; + } + $elapsed = microtime(true) - $started; + + $this->assertInstanceOf(ProcessSignaledException::class, $signaled, sprintf('exit code %s: %s', var_export($process->getExitCode(), true), $process->getErrorOutput())); + $this->assertSame(15, $process->getTermSignal()); + $this->assertLessThan(self::SIGNAL_DEADLINE, $elapsed); + $this->assertSame('', $process->getOutput()); + $this->assertSame('', $process->getErrorOutput()); + $this->assertSame([], $this->resourceNames($base)); + } + + #[MaximumDuration(10000)] + public function testWithTheHandlersDisabledANormalExitStillDeliversThroughTheShutdownFunction(): void + { + $base = $this->collector()->scenario(); + $process = $this->fixture($base, 'disabled-shutdown'); + + $process->run(); + + $this->assertSame(0, $process->getExitCode(), $process->getErrorOutput()); + $this->assertSame("ending\n", $process->getOutput()); + $this->assertSame(['signal-drain'], $this->resourceNames($base)); + } + #[Group('ac-03-11')] #[MaximumDuration(10000)] public function testWithACustomerHandlerTheQueueDrainsThenTheHandlerRunsAndTheProcessGoesOn(): void diff --git a/tests/Integration/Delivery/SignalInCallbackTest.php b/tests/Integration/Delivery/SignalInCallbackTest.php new file mode 100644 index 0000000..7b6c0f4 --- /dev/null +++ b/tests/Integration/Delivery/SignalInCallbackTest.php @@ -0,0 +1,120 @@ +stop(); + self::$collector = null; + } + + #[\Override] + protected function setUp(): void + { + $this->dir = getenv('HOME') . '/signal-in-callback-' . bin2hex(random_bytes(6)); + $this->assertTrue(mkdir($this->dir, 0o700)); + } + + #[\Override] + protected function tearDown(): void + { + $names = scandir($this->dir); + foreach ($names === false ? [] : $names as $name) { + if (is_file($this->dir . '/' . $name)) { + unlink($this->dir . '/' . $name); + } + } + rmdir($this->dir); + } + + /** + * A drain inside a write callback, then a returning earlier handler, many times over: the process does not crash, + * every side request is answered without an error, and the drain delivered the event on the transport's handle. + * Measured locally: about 0.3 s, with about 100 of 900 signals inside curl_multi_exec(). + */ + #[MaximumDuration(10000)] + #[RequiresPhpExtension('pcntl')] + #[RequiresPhpExtension('posix')] + public function testADrainInsideACurlCallbackDoesNotCrashTheProcess(): void + { + $events = $this->collector()->scenario(); + $side = $this->collector()->scenario([], ['status' => 200, 'body' => str_repeat('x', 4_000_000)]); + $process = new Process( + [PHP_BINARY, __DIR__ . '/../Fixtures/signal-in-callback.php', $events, $side, (string) self::ROUNDS], + null, + ['HOME' => $this->dir, 'AGENTCAT_DIAGNOSTICS_ENDPOINT' => MockCollector::closedPortUrl()], + ); + $process->setTimeout(20); + + try { + $process->run(); + } catch (ProcessSignaledException $e) { + self::fail('the fixture crashed: signal ' . $e->getSignal()); + } + + $this->assertFalse($process->hasBeenSignaled()); + $this->assertSame(0, $process->getExitCode(), $process->getErrorOutput()); + $this->assertSame('', $process->getErrorOutput()); + $this->assertSame(1, preg_match('/\Asurvived signals=(\d+) nested=(\d+) done=(\d+) errors=(\d+)\n\z/', $process->getOutput(), $match), $process->getOutput()); + $this->assertGreaterThan(0, (int) $match[2], 'some drains ran inside curl_multi_exec()'); + $this->assertGreaterThanOrEqual((int) $match[2], (int) $match[1], 'each drain called the earlier handler'); + $this->assertSame(self::ROUNDS, (int) $match[3], 'every side request was answered'); + $this->assertSame(0, (int) $match[4], 'without an error'); + $this->assertCount(1, $this->collector()->requests($events), 'the drain delivered the event'); + } + + /** + * A handler that calls exit() inside a write callback: the callback ends before it clears the channel's busy flag. + * The channel sees that no curl_multi_exec() runs any more, so the shutdown function can still send on it. + */ + #[MaximumDuration(10000)] + public function testAnExitInsideACurlCallbackLeavesTheChannelUsableAtShutdown(): void + { + $side = $this->collector()->scenario([], ['status' => 200, 'body' => str_repeat('x', 200_000)]); + $process = new Process( + [PHP_BINARY, __DIR__ . '/../Fixtures/exit-in-callback.php', $side], + null, + ['HOME' => $this->dir, 'AGENTCAT_DIAGNOSTICS_ENDPOINT' => MockCollector::closedPortUrl()], + ); + $process->setTimeout(10); + + $process->run(); + + $this->assertSame(0, $process->getExitCode(), $process->getErrorOutput()); + $this->assertSame("exit\nafter: 200 -\npending: 0\n", $process->getOutput()); + $this->assertSame('', $process->getErrorOutput()); + } + + private function collector(): MockCollector + { + $this->assertInstanceOf(MockCollector::class, self::$collector); + + return self::$collector; + } +} diff --git a/tests/Integration/Diagnostics/DiagnosticsTimeoutsTest.php b/tests/Integration/Diagnostics/DiagnosticsTimeoutsTest.php new file mode 100644 index 0000000..d4031a3 --- /dev/null +++ b/tests/Integration/Diagnostics/DiagnosticsTimeoutsTest.php @@ -0,0 +1,84 @@ + the listening socket and the connections that fill its backlog */ + private array $sockets = []; + + #[\Override] + protected function setUp(): void + { + $this->environment = Environment::clear('AGENTCAT_DISABLE_DIAGNOSTICS', 'AGENTCAT_DIAGNOSTICS_ENDPOINT', 'AGENTCAT_DIAGNOSTICS_TOKEN', 'DISABLE_DIAGNOSTICS', 'DIAGNOSTICS_ENDPOINT', 'DIAGNOSTICS_TOKEN'); + Diagnostics::reset(); + Log::setLogger(new MemoryLogger()); + } + + #[\Override] + protected function tearDown(): void + { + foreach ($this->sockets as $socket) { + fclose($socket); + } + Diagnostics::reset(); + Log::reset(); + $this->environment->restore(); + } + + /** + * A firewall that drops the packets to otel.agentcat.com: the connection never opens. The shutdown waits for the + * batch only until the 250 ms connect timeout (exporters keep 2 s), not for its 5 s budget. + */ + #[MaximumDuration(3000)] + public function testABlackHoledCollectorEndsTheShutdownWaitAfterTheConnectTimeout(): void + { + putenv('AGENTCAT_DIAGNOSTICS_ENDPOINT=' . $this->blackHole()); + $channel = new CurlHttpChannel(); + $queue = new EventQueue(channel: $channel); + Diagnostics::init('proj_ABC', false, $queue, static fn(): bool => false); + Log::write('one'); + $started = hrtime(true); + + $queue->shutdown(); + + $elapsed = (hrtime(true) - $started) / 1e9; + $this->assertGreaterThanOrEqual(0.2, $elapsed, 'the shutdown waited for the batch'); + $this->assertLessThan(Diagnostics::CONNECT_TIMEOUT_SECONDS + 1.0, $elapsed, 'and stopped at the 250 ms connect timeout'); + $this->assertSame(0, $channel->pending(), 'the batch failed at its timeout, not abandoned at the deadline'); + } + + /** + * A local endpoint that drops connection attempts: a listening socket with a backlog of 0 and one connection that + * fills its accept queue, so Linux drops every further SYN and a client's connect waits for its timeout. Nothing + * leaves 127.0.0.1. + */ + private function blackHole(): string + { + $context = stream_context_create(['socket' => ['backlog' => 0]]); + $server = stream_socket_server('tcp://127.0.0.1:0', $errorCode, $errorMessage, STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, $context); + $this->assertIsResource($server, $errorMessage ?? ''); + $this->sockets[] = $server; + $address = stream_socket_get_name($server, false); + $this->assertIsString($address); + $filler = stream_socket_client('tcp://' . $address, $errorCode, $errorMessage, 1.0); // connected: the queue is full + $this->assertIsResource($filler, $errorMessage ?? ''); + $this->sockets[] = $filler; + + return 'http://' . $address; + } +} diff --git a/tests/Integration/Diagnostics/HardenedHostTest.php b/tests/Integration/Diagnostics/HardenedHostTest.php new file mode 100644 index 0000000..e09cc99 --- /dev/null +++ b/tests/Integration/Diagnostics/HardenedHostTest.php @@ -0,0 +1,77 @@ +dir = getenv('HOME') . '/hardened-' . bin2hex(random_bytes(6)); + $this->assertTrue(mkdir($this->dir, 0o700)); + } + + #[\Override] + protected function tearDown(): void + { + $names = scandir($this->dir); + foreach ($names === false ? [] : $names as $name) { + if (is_file($this->dir . '/' . $name)) { + unlink($this->dir . '/' . $name); + } + } + rmdir($this->dir); + } + + #[MaximumDuration(5000)] + public function testDiagnosticsStartWithoutTheAttributesOfDisabledFunctions(): void + { + $result = $this->dir . '/result.json'; + $collector = MockCollector::closedPortUrl(); + $process = new Process( + [PHP_BINARY, '-d', 'disable_functions=php_uname,getmypid,gethostname', __DIR__ . '/../Fixtures/diagnostics-hardened.php', $result], + null, + // The script records its requests; a closed port in case it ever sends one. + ['HOME' => $this->dir, 'AGENTCAT_DIAGNOSTICS_ENDPOINT' => $collector, 'AGENTCAT_DISABLE_DIAGNOSTICS' => false, 'DISABLE_DIAGNOSTICS' => false, 'AGENTCAT_DIAGNOSTICS_TOKEN' => false, 'APP_ENV' => false], + ); + $process->setTimeout(10); + + $process->run(); + + $this->assertSame('', $process->getOutput()); + $this->assertSame('', $process->getErrorOutput()); + $this->assertSame(0, $process->getExitCode()); + $observed = Json::decode((string) file_get_contents($result)); + $this->assertTrue(JsonAssert::at($observed, 'enabled'), 'diagnostics started'); + $this->assertSame([$collector . '/v1/logs'], JsonAssert::at($observed, 'urls')); + $body = JsonAssert::at($observed, 'body'); + $this->assertIsString($body); + $attributes = JsonAssert::at(Json::decode($body), 'resourceLogs', 0, 'resource', 'attributes'); + $this->assertIsList($attributes); + $pairs = []; + foreach ($attributes as $attribute) { + $key = JsonAssert::at($attribute, 'key'); + $this->assertIsString($key); + $pairs[$key] = JsonAssert::at($attribute, 'value', 'stringValue'); + } + $this->assertSame(['agentcat.install_id', 'agentcat.sdk.language', 'agentcat.sdk.version', 'agentcat.mcp_sdk.version', 'process.runtime.name', 'process.runtime.version', 'os.type'], array_keys($pairs)); + $this->assertSame(DiagnosticsAttributes::installId('', 'file://' . dirname(__DIR__, 3)), $pairs['agentcat.install_id'] ?? null, 'an empty host name'); + $record = JsonAssert::at(Json::decode($body), 'resourceLogs', 0, 'scopeLogs', 0, 'logRecords', 0, 'body', 'stringValue'); + $this->assertIsString($record); + $this->assertStringEndsWith('] Warning: hardened', $record); + } +} diff --git a/tests/Integration/Fixtures/diagnostics-hardened.php b/tests/Integration/Fixtures/diagnostics-hardened.php new file mode 100644 index 0000000..f0fc7df --- /dev/null +++ b/tests/Integration/Fixtures/diagnostics-hardened.php @@ -0,0 +1,26 @@ +shutdown(); +file_put_contents($argv[1] ?? '', json_encode([ + 'enabled' => Diagnostics::enabled(), + 'urls' => $http->urls(), + 'body' => isset($http->requests[0]) ? $http->requests[0]->body : null, +], JSON_THROW_ON_ERROR)); diff --git a/tests/Integration/Fixtures/exit-in-callback.php b/tests/Integration/Fixtures/exit-in-callback.php new file mode 100644 index 0000000..249d848 --- /dev/null +++ b/tests/Integration/Fixtures/exit-in-callback.php @@ -0,0 +1,46 @@ + +// Prints "exit", then from the shutdown function "after: " and "pending: ". +$url = $argv[1] ?? ''; +$exited = false; +$channel = new CurlHttpChannel(onCallback: static function (string $kind) use (&$exited): void { + if ($kind === 'write' && !$exited) { + $exited = true; + echo "exit\n"; + + exit(0); + } +}); +register_shutdown_function(static function () use ($channel, $url): void { + $channel->post(new HttpRequest($url . '/after', [], ''), static function (HttpResponse $response): void { + echo 'after: ', $response->status, ' ', $response->error ?? '-', "\n"; + }); + $until = microtime(true) + 3.0; + while ($channel->pending() > 0 && microtime(true) < $until) { + $channel->pump(); + $channel->waitForActivity(0.05); + } + echo 'pending: ', $channel->pending(), "\n"; +}); +$channel->post(new HttpRequest($url . '/first', [], ''), static function (): void {}); +$until = microtime(true) + 3.0; +while ($channel->pending() > 0 && microtime(true) < $until) { + $channel->pump(); + $channel->waitForActivity(0.05); +} +echo "not reached\n"; diff --git a/tests/Integration/Fixtures/signal-after-exec.php b/tests/Integration/Fixtures/signal-after-exec.php new file mode 100644 index 0000000..6c9e989 --- /dev/null +++ b/tests/Integration/Fixtures/signal-after-exec.php @@ -0,0 +1,102 @@ +" to STDERR. +// Usage: signal-after-exec.php +$mode = $argv[1] ?? ''; +$largeUrl = $argv[2] ?? ''; +$eventsUrl = $argv[3] ?? ''; +$exportsUrl = $argv[4] ?? ''; +$execLine = 0; +$source = file(dirname(__DIR__, 3) . '/src/Internal/Transport/CurlMulti.php'); +foreach ($source === false ? [] : $source as $index => $line) { + if (str_contains($line, 'curl_multi_exec($this->multi()')) { + $execLine = $index + 1; + } +} +$caller = $mode === 'transport' ? CurlTransport::class : CurlHttpChannel::class; + +pcntl_signal(SIGUSR1, SIG_IGN); // until the probe handler below is installed +$parent = posix_getpid(); +$child = pcntl_fork(); +if ($child === 0) { + $until = microtime(true) + 15.0; + while (microtime(true) < $until && posix_kill($parent, SIGUSR1)) { + usleep(200); + } + posix_kill(posix_getpid(), SIGKILL); // no shutdown function or destructor of the parent's objects runs here +} + +$channel = new CurlHttpChannel(); +$exports = new readonly class ($channel, $exportsUrl) implements ExporterSink { + public function __construct(private HttpChannel $channel, private string $url) {} + + #[\Override] + public function export(Event $event): void + { + $this->channel->post(new HttpRequest($this->url . '/' . $event->resourceName, [], '{}'), static function (): void {}); + } +}; +$transport = new CurlTransport(new RetryPolicy([0.01, 0.02, 0.04])); +$queue = new EventQueue(transport: $transport, exporters: $exports, channel: $channel); +$queue->configure($mode === 'transport' ? $largeUrl : $eventsUrl); +(new SignalDrain(static fn(): EventQueue => $queue))->install(); +$sent = false; +pcntl_async_signals(true); +pcntl_signal(SIGUSR1, static function () use ($queue, $eventsUrl, $execLine, $caller, $child, &$sent): void { + $frames = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS); + $exec = ($frames[1]['function'] ?? '') === 'curl_multi_exec' ? 1 : 0; // PHP 8.4+: the returned call's frame is still there + $at = $frames[$exec] ?? []; + $run = $frames[$exec + 1] ?? []; + $after = str_ends_with($at['file'] ?? '', '/CurlMulti.php') && ($at['line'] ?? 0) === $execLine && ($run['function'] ?? '') === 'run'; + if ($sent || !$after || ($frames[$exec + 2]['class'] ?? '') !== $caller) { + return; + } + $sent = true; + posix_kill($child, SIGKILL); + $queue->configure($eventsUrl); + $queue->add(new QueuedEvent(new Event(projectId: 'proj_test', eventType: 'mcp:tools/call', resourceName: 'drain'))); + fwrite(STDERR, sprintf("sigterm %.6F\n", microtime(true))); + posix_kill(posix_getpid(), SIGTERM); // pending: PHP runs it right after this handler +}); +for ($round = 0; $round < 20; ++$round) { + if ($mode === 'transport') { + $queue->add(new QueuedEvent(new Event(projectId: 'proj_test', eventType: 'mcp:tools/call', resourceName: 'large-' . $round))); + } else { + $channel->post(new HttpRequest($largeUrl . '/large-' . $round, [], '{}'), static function (): void {}); + } + $until = microtime(true) + 5.0; + while ($queue->hasWork() && microtime(true) < $until) { + $queue->pump(); + if ($mode === 'transport') { + $transport->waitForActivity($transport->now() + 0.05); + } else { + $channel->waitForActivity(0.05); + } + } +} +pcntl_signal(SIGUSR1, SIG_IGN); +posix_kill($child, SIGKILL); +pcntl_waitpid($child, $status); +echo "not ended by SIGTERM\n"; diff --git a/tests/Integration/Fixtures/signal-drain.php b/tests/Integration/Fixtures/signal-drain.php index 5b130dc..fa30d9d 100644 --- a/tests/Integration/Fixtures/signal-drain.php +++ b/tests/Integration/Fixtures/signal-drain.php @@ -19,6 +19,9 @@ // ; the script goes on and prints "done". // chain-exit: its own SIGTERM handler calls exit(0). It prints "signaled" just before it sends itself SIGTERM. // twice: like default, but it prints "signaled" just before the SIGTERM, so that the test can send another. +// disabled: disableSignalHandlers is on: it calls EventQueue::global()->disableSignalHandlers() before the event, then +// sends itself SIGTERM. Nothing delivers; SIGTERM ends the process at once. +// disabled-shutdown: like disabled, but it prints "ending" and ends normally: the shutdown function delivers the event. // shutdown: no signal of its own: it prints "ending" and ends, so that the shutdown function delivers the event. $baseUrl = $argv[1] ?? ''; $mode = $argv[2] ?? ''; @@ -34,9 +37,12 @@ }); } $queue = EventQueue::global(); +if ($mode === 'disabled' || $mode === 'disabled-shutdown') { + $queue->disableSignalHandlers(); +} $queue->configure($baseUrl); $queue->add(new QueuedEvent(new Event(projectId: 'proj_test', eventType: 'mcp:tools/call', resourceName: 'signal-drain'))); -if ($mode === 'shutdown') { +if ($mode === 'shutdown' || $mode === 'disabled-shutdown') { echo "ending\n"; exit(0); diff --git a/tests/Integration/Fixtures/signal-in-callback.php b/tests/Integration/Fixtures/signal-in-callback.php new file mode 100644 index 0000000..9bca6d8 --- /dev/null +++ b/tests/Integration/Fixtures/signal-in-callback.php @@ -0,0 +1,84 @@ + +// Prints: "survived signals= nested= done= errors=" +$eventsUrl = $argv[1] ?? ''; +$sideUrl = $argv[2] ?? ''; +$rounds = (int) ($argv[3] ?? '0'); + +pcntl_signal(SIGTERM, SIG_IGN); // until the handler below is installed +$parent = posix_getpid(); +$child = pcntl_fork(); +if ($child === 0) { + $until = microtime(true) + 15.0; + while (microtime(true) < $until && posix_kill($parent, SIGTERM)) { + usleep(200); + } + posix_kill(posix_getpid(), SIGKILL); // no shutdown function or destructor of the parent's objects runs here +} + +$channel = new CurlHttpChannel(); +$queue = new EventQueue(shutdownTimeoutSeconds: 30.0, channel: $channel); +$queue->configure($eventsUrl); +$queue->add(new QueuedEvent(new Event(projectId: 'proj_test', eventType: 'mcp:tools/call', resourceName: 'signal-in-callback'))); +$drain = new SignalDrain(static fn(): EventQueue => $queue); +$signals = 0; +$nested = 0; +$earlier = static function () use (&$signals): void { + ++$signals; +}; +pcntl_async_signals(true); +pcntl_signal(SIGTERM, static function (int $signo, mixed $info = null) use ($drain, $earlier, &$nested): void { + foreach (debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS) as $index => $frame) { + // Inside a callback, the callback's frame lies between this handler and curl_multi_exec(). Right below the + // handler, curl_multi_exec() has returned already (PHP 8.4+ runs the signal before it pops that frame). + if ($frame['function'] === 'curl_multi_exec' && $index > 1) { + ++$nested; + $drain->onSignal($signo, $earlier, $info); // delivers the event (once), then calls $earlier + + return; + } + } + $earlier(); +}); + +$done = 0; +$errors = 0; +for ($round = 0; $round < $rounds; ++$round) { + $channel->post(new HttpRequest($sideUrl . '/r' . $round, [], ''), static function (HttpResponse $response) use (&$done, &$errors): void { + ++$done; + if (!$response->ok()) { + ++$errors; + } + }); + $until = microtime(true) + 5.0; + while ($channel->pending() > 0 && microtime(true) < $until) { + $channel->pump(); + $channel->waitForActivity(0.05); + } +} +pcntl_signal(SIGTERM, SIG_IGN); +posix_kill($child, SIGKILL); +pcntl_waitpid($child, $status); +echo sprintf("survived signals=%d nested=%d done=%d errors=%d\n", $signals, $nested, $done, $errors); diff --git a/tests/Integration/Fixtures/throw-after-exec.php b/tests/Integration/Fixtures/throw-after-exec.php new file mode 100644 index 0000000..8af9f8c --- /dev/null +++ b/tests/Integration/Fixtures/throw-after-exec.php @@ -0,0 +1,81 @@ + times. On PHP 8.3 a throw at that moment corrupted the frame when the exec call was nested in another call's +// arguments (I1: a segfault, a hang or an out-of-memory error). The transport swallows the throwable (D32); the server +// goes on, delivers every event and exits 0. +// Usage: throw-after-exec.php +// Prints: "throws= left=" +$largeUrl = $argv[1] ?? ''; +$wanted = (int) ($argv[2] ?? '5'); +$execLine = 0; +$source = file(dirname(__DIR__, 3) . '/src/Internal/Transport/CurlMulti.php'); +foreach ($source === false ? [] : $source as $index => $line) { + if (str_contains($line, 'curl_multi_exec($this->multi()')) { + $execLine = $index + 1; + } +} + +pcntl_signal(SIGUSR1, SIG_IGN); // until the probe handler below is installed +$parent = posix_getpid(); +$child = pcntl_fork(); +if ($child === 0) { + $until = microtime(true) + 15.0; + while (microtime(true) < $until && posix_kill($parent, SIGUSR1)) { + usleep(100); + } + posix_kill(posix_getpid(), SIGKILL); // no shutdown function or destructor of the parent's objects runs here +} + +$transport = new CurlTransport(new RetryPolicy([0.01, 0.02, 0.04])); +$queue = new EventQueue(transport: $transport); +$queue->configure($largeUrl); +$throws = 0; +pcntl_async_signals(true); +pcntl_signal(SIGUSR1, static function () use ($queue, $execLine, $wanted, &$throws): void { + if ($throws >= $wanted) { + return; + } + $frames = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS); + $exec = ($frames[1]['function'] ?? '') === 'curl_multi_exec' ? 1 : 0; // PHP 8.4+: the returned call's frame is still there + $at = $frames[$exec] ?? []; + $run = $frames[$exec + 1] ?? []; + $after = str_ends_with($at['file'] ?? '', '/CurlMulti.php') && ($at['line'] ?? 0) === $execLine + && ($run['function'] ?? '') === 'run' && ($run['class'] ?? '') === CurlMulti::class; + if (!$after || ($frames[$exec + 2]['class'] ?? '') !== CurlTransport::class) { + return; + } + ++$throws; + $queue->add(new QueuedEvent(new Event(projectId: 'proj_test', eventType: 'mcp:tools/call', resourceName: 'thrown-' . $throws))); + + throw new \RuntimeException('a tool timeout from a signal handler'); +}); +for ($round = 0; $round < 40 && $throws < $wanted; ++$round) { + $queue->add(new QueuedEvent(new Event(projectId: 'proj_test', eventType: 'mcp:tools/call', resourceName: 'large-' . $round))); + $until = microtime(true) + 5.0; + while ($queue->hasWork() && microtime(true) < $until) { + $queue->pump(); + $transport->waitForActivity($transport->now() + 0.05); + } +} +pcntl_signal(SIGUSR1, SIG_IGN); +posix_kill($child, SIGKILL); +pcntl_waitpid($child, $status); +$queue->flush(5.0); +echo sprintf("throws=%d left=%d\n", $throws, $queue->stats()['queueLength'] + $queue->stats()['activeRequests']); diff --git a/tests/Integration/Support/MockCollector.php b/tests/Integration/Support/MockCollector.php index b53598b..6f5680d 100644 --- a/tests/Integration/Support/MockCollector.php +++ b/tests/Integration/Support/MockCollector.php @@ -60,8 +60,9 @@ public static function start(): self /** * A new scenario; returns its base URL (`http://127.0.0.1:/c/`). * - * @param list $steps responses to requests 0, 1, 2, … - * @param array{status: int, delayMs?: int, body?: string} $default the response once the steps run out + * @param list $steps responses to requests 0, 1, 2, …; + * `location` is sent as the Location header + * @param array{status: int, delayMs?: int, body?: string, location?: string} $default the response once the steps run out */ public function scenario(array $steps = [], array $default = ['status' => 202, 'body' => '{"status":"accepted"}']): string { diff --git a/tests/Integration/Support/router.php b/tests/Integration/Support/router.php index 3ed404e..9a481e1 100644 --- a/tests/Integration/Support/router.php +++ b/tests/Integration/Support/router.php @@ -4,7 +4,8 @@ // Mock AgentCat collector for the transport and queue tests (php -S, PHP_CLI_SERVER_WORKERS=8). // Path /c//. Files in $AGENTCAT_COLLECTOR_DIR: -// .json {"steps":[{"status":503,"delayMs":0,"body":""}],"default":{"status":202,"body":"{\"status\":\"accepted\"}"}} +// .json {"steps":[{"status":503,"delayMs":0,"body":"","location":"http://…"}],"default":{"status":202,"body":"{\"status\":\"accepted\"}"}} +// (`location`, optional, is sent as the Location header) // .log one JSON line per request (n, method, path, headers, body, start, end), written before the response. $dir = getenv('AGENTCAT_COLLECTOR_DIR'); @@ -37,6 +38,7 @@ $status = $step instanceof stdClass && is_int($step->status ?? null) ? $step->status : 202; $delayMs = $step instanceof stdClass && is_int($step->delayMs ?? null) ? $step->delayMs : 0; $responseBody = $step instanceof stdClass && is_string($step->body ?? null) ? $step->body : ''; +$location = $step instanceof stdClass && is_string($step->location ?? null) ? $step->location : null; $requestBody = file_get_contents('php://input'); if ($delayMs > 0) { @@ -54,6 +56,9 @@ http_response_code($status); header('Content-Type: application/json'); +if ($location !== null) { + header('Location: ' . $location); // after http_response_code(): PHP sends 302 unless a 201 or 3xx is set +} echo $responseBody; return true; diff --git a/tests/Integration/Transport/CurlHttpChannelTest.php b/tests/Integration/Transport/CurlHttpChannelTest.php new file mode 100644 index 0000000..f5914f9 --- /dev/null +++ b/tests/Integration/Transport/CurlHttpChannelTest.php @@ -0,0 +1,679 @@ + what the channels of this test swallowed (I2: the normal paths swallow nothing) */ + private array $swallowed = []; + + #[\Override] + public static function setUpBeforeClass(): void + { + self::$collector = MockCollector::start(); + } + + #[\Override] + public static function tearDownAfterClass(): void + { + self::$collector?->stop(); + self::$collector = null; + } + + #[\Override] + protected function setUp(): void + { + $this->logger = new MemoryLogger(); + Log::setLogger($this->logger); + } + + #[\Override] + protected function tearDown(): void + { + Log::reset(); + $this->assertSame([], array_map(ErrorText::of(...), $this->swallowed), 'a channel swallowed a throwable'); + } + + #[MaximumDuration(3000)] + public function testAPostCarriesItsHeadersAndBodyAndReportsTheAnswer(): void + { + $url = $this->collector()->scenario([['status' => 404, 'body' => 'nope']]); + $channel = $this->channel(); + $responses = []; + $channel->post( + new HttpRequest($url . '/v1/traces', ['Content-Type' => 'application/json', 'x-api-key' => 'k'], '{"a":1}'), + static function (HttpResponse $response) use (&$responses): void { + $responses[] = $response; + }, + ); + + $this->assertTrue($this->drain($channel, 2.0)); + + $this->assertCount(1, $responses); + $this->assertSame(404, $responses[0]->status); + $this->assertSame('Not Found', $responses[0]->reason); + $this->assertSame('nope', $responses[0]->body); + $this->assertNull($responses[0]->error); + $this->assertFalse($responses[0]->ok()); + $requests = $this->collector()->requests($url); + $this->assertCount(1, $requests); + $request = $requests[0]; + $this->assertSame('POST', $request->method); + $this->assertSame('/v1/traces', $request->path); + $this->assertSame('{"a":1}', $request->body); + $this->assertSame('k', $this->header($request, 'x-api-key')); + $this->assertSame('application/json', $this->header($request, 'Content-Type')); + $this->assertStringStartsWith('agentcat-php/', (string) $this->header($request, 'User-Agent')); + $this->assertSame([], $this->logger->messages()); + } + + #[Group('ac-06-63')] + #[MaximumDuration(3000)] + public function testARefusedConnectionAndATimeoutAreErrorsNotExceptions(): void + { + $slow = $this->collector()->scenario([], ['status' => 200, 'delayMs' => 1000]); + $channel = $this->channel(1.0, 0.3); + $errors = []; + $record = static function (HttpResponse $response) use (&$errors): void { + $errors[] = $response->error; + }; + $channel->post(new HttpRequest(MockCollector::closedPortUrl() . '/v1/logs', [], '{}'), $record); + $channel->post(new HttpRequest($slow . '/v1/logs', [], '{}'), $record); + + $this->assertTrue($this->drain($channel, 2.0)); + + sort($errors); + $this->assertCount(2, $errors); + $this->assertMatchesRegularExpression('/\AcURL error 28: /', (string) $errors[0]); + $this->assertMatchesRegularExpression('/\AcURL error 7: /', (string) $errors[1]); + $this->assertSame([], $this->logger->messages(), 'a failed request reaches only its callback'); + } + + /** Review Focus 5: no file is read and no redirect is followed. */ + #[MaximumDuration(3000)] + public function testOnlyHttpAndHttpsAreFetched(): void + { + $target = $this->collector()->scenario(); + $moved = $this->collector()->scenario([['status' => 301, 'body' => '', 'location' => $target . '/followed']]); + $channel = $this->channel(); + $responses = []; + $record = static function (HttpResponse $response) use (&$responses): void { + $responses[] = $response; + }; + $channel->post(new HttpRequest('file:///etc/hostname', [], ''), $record); + $channel->post(new HttpRequest($moved . '/batch', [], '{}'), $record); + + $this->assertTrue($this->drain($channel, 2.0)); + + $byUrl = []; + foreach ($responses as $response) { + $byUrl[$response->error === null ? 'http' : 'file'] = $response; + } + $this->assertArrayHasKey('file', $byUrl); + $this->assertArrayHasKey('http', $byUrl); + $this->assertMatchesRegularExpression('/\AcURL error 1: /', (string) $byUrl['file']->error); + $this->assertSame('', $byUrl['file']->body); + $this->assertSame(301, $byUrl['http']->status); + $this->assertCount(1, $this->collector()->requests($moved)); + $this->assertSame([], $this->collector()->requests($target), 'the redirect was not followed'); + } + + #[MaximumDuration(3000)] + public function testACallbackCanPostTheNextRequestOfASequence(): void + { + $url = $this->collector()->scenario(); + $channel = $this->channel(); + $order = []; + $channel->post(new HttpRequest($url . '/one', [], '1'), static function () use ($channel, $url, &$order): void { + $order[] = 'one'; + $channel->post(new HttpRequest($url . '/two', [], '2'), static function () use (&$order): void { + $order[] = 'two'; + }); + }); + + $this->assertTrue($this->drain($channel, 2.0)); + + $this->assertSame(['one', 'two'], $order); + } + + #[MaximumDuration(3000)] + public function testARequestThatACallbackPostsStartsInTheSamePump(): void + { + $url = $this->collector()->scenario(); + $channel = $this->channel(); + $posted = new \ArrayObject(); + $channel->post(new HttpRequest($url . '/one', [], '1'), static function () use ($channel, $url, $posted): void { + $channel->post(new HttpRequest($url . '/two', [], '2'), static function (): void {}); + $posted[] = true; + }); + $until = microtime(true) + 2.0; + + do { + $this->assertLessThan($until, microtime(true), 'the first request did not finish'); + $channel->waitForActivity(0.05); + $channel->pump(); + } while (count($posted) === 0); + + $this->assertSame(1, $channel->pending()); + $this->assertSame(1, $channel->inTransfer(), 'the posted request is in transfer when the pump returns'); + $this->assertTrue($this->drain($channel, 2.0)); + $this->assertCount(2, $this->collector()->requests($url)); + } + + /** + * A long-running worker keeps its channel: a finished transfer leaves its handle in no multi handle (the request's + * callback is freed with it), and the channel is in no reference cycle. + */ + #[MaximumDuration(3000)] + public function testAFinishedTransferLeavesNothingBehind(): void + { + $url = $this->collector()->scenario(); + $channel = $this->channel(); + $statuses = []; + $onDone = static function (HttpResponse $response) use (&$statuses): void { + $statuses[] = $response->status; + }; + $request = \WeakReference::create($onDone); + $channel->post(new HttpRequest($url . '/a', [], '{}'), $onDone); + unset($onDone); + + $this->assertTrue($this->drain($channel, 2.0)); + + $this->assertSame([202], $statuses); + $this->assertNull($request->get(), 'the finished handle let go of the request'); + $weak = \WeakReference::create($channel); + unset($channel); + $this->assertNull($weak->get(), 'freed without the cycle collector'); + } + + #[MaximumDuration(3000)] + public function testTheResponseBodyIsCappedAt64KiB(): void + { + $url = $this->collector()->scenario([['status' => 500, 'body' => str_repeat('x', 70_000)]]); + $channel = $this->channel(); + $body = null; + $channel->post(new HttpRequest($url . '/batch', [], '{}'), static function (HttpResponse $response) use (&$body): void { + $body = $response->body; + }); + + $this->assertTrue($this->drain($channel, 2.0)); + + $this->assertSame(CurlHttpChannel::MAX_BODY_BYTES, strlen((string) $body)); + } + + /** I1: a background request in transfer is left out of `pending(false)` too. */ + #[MaximumDuration(3000)] + public function testABackgroundRequestInTransferIsLeftOutOfTheForegroundCount(): void + { + $url = $this->collector()->scenario([], ['status' => 200, 'delayMs' => 300]); + $channel = $this->channel(); + $channel->post(new HttpRequest($url . '/v1/logs', [], '{}', background: true), static function (): void {}); + $channel->post(new HttpRequest($url . '/batch', [], '{}'), static function (): void {}); + $channel->post(new HttpRequest($url . '/v1/traces', [], '{}'), static function (): void {}); + $channel->pump(); + + $this->assertSame(3, $channel->inTransfer()); + $this->assertSame(3, $channel->pending()); + $this->assertSame(2, $channel->pending(false)); + $this->assertTrue($this->drain($channel, 2.0)); + } + + /** + * I2 and the N1 ruling: a signal handler (the SIGTERM drain) that runs inside a callback pumps, waits on and posts + * to the channel it interrupted. In the `header` callback the seam clears the busy flag first, as at PHP 8.4+'s + * callback entry: the nested pump's add and run are refused, the refusal marks the channel busy, and the request + * waits. In the `write` callback the channel is busy already. Either way: nothing pending, no wait, nothing failed, + * and every request completes. + */ + #[MaximumDuration(5000)] + public function testAPumpFromInsideATransferCallbackLeavesTheChannelAlone(): void + { + $url = $this->collector()->scenario([], ['status' => 200, 'body' => str_repeat('x', 200_000)]); + $responses = []; + $record = static function (string $name) use (&$responses): \Closure { + return static function (HttpResponse $response) use ($name, &$responses): void { + $responses[$name] = [$response->status, $response->error]; + }; + }; + $nested = []; + $channel = null; + $channel = $this->channel(onCallback: static function (string $kind, CallbackFlag $busy) use (&$channel, &$nested, $url, $record): void { + if (isset($nested[$kind]) || !$channel instanceof CurlHttpChannel) { + return; + } + if ($kind === 'header') { + $busy->active = false; // PHP 8.4+ runs a pending signal at the callback's entry, before the flag is set + } + $begin = hrtime(true); + $channel->post(new HttpRequest($url . '/' . $kind, [], ''), $record($kind)); + $channel->pump(); + for ($i = 0; $i < 3; ++$i) { + $channel->waitForActivity(1.0); // 3 s if the channel waited + } + $nested[$kind] = [(hrtime(true) - $begin) / 1e9 < 1.5, $channel->pending(), $channel->pending(false), $channel->inTransfer()]; + }); + $channel->post(new HttpRequest($url . '/a', [], ''), $record('a')); + + $this->assertTrue($this->drain($channel, 3.0)); + + ksort($responses); + $this->assertSame(['a' => [200, null], 'header' => [200, null], 'write' => [200, null]], $responses, 'no curl error, no lost transfer'); + ksort($nested); + $this->assertSame(['header' => [true, 0, 0, 2], 'write' => [true, 0, 0, 2]], $nested, 'in each callback: no wait, nothing pending; a and the refused add of header in transfer'); + $this->assertCount(3, $this->collector()->requests($url)); + } + + /** + * A callback that ends early (a throwable, or `exit()` in a signal handler) leaves the busy flag set. Outside + * `curl_multi_exec()` the channel sees that and goes on: the next request is sent as usual. + */ + #[MaximumDuration(5000)] + public function testABusyFlagThatACallbackLeftSetIsClearedOutsideCurl(): void + { + $url = $this->collector()->scenario([], ['status' => 200, 'body' => str_repeat('x', 200_000)]); + /** @var \ArrayObject $thrown */ + $thrown = new \ArrayObject(); + $failure = new \RuntimeException('a callback that ends early'); + $channel = new CurlHttpChannel(onSwallowed: static function (\Throwable $error) use ($thrown): void { + $thrown[] = $error; + }, onCallback: static function (string $kind) use ($thrown, $failure): void { + if ($kind === 'write' && count($thrown) === 0) { + throw $failure; + } + }); + $responses = []; + $record = static function (string $name) use (&$responses): \Closure { + return static function (HttpResponse $response) use ($name, &$responses): void { + $responses[$name] = $response->status; + }; + }; + $channel->post(new HttpRequest($url . '/a', [], ''), $record('a')); + $this->assertTrue($this->drain($channel, 3.0)); + $this->assertSame([$failure], $thrown->getArrayCopy(), 'the throwable reached only the channel'); + + $channel->post(new HttpRequest($url . '/b', [], ''), $record('b')); + + $this->assertSame(1, $channel->pending(), 'not busy outside curl'); + $this->assertTrue($this->drain($channel, 3.0)); + $this->assertSame(200, $responses['b'] ?? null, 'and the next request went out'); + } + + /** + * php -S hands the 12 requests to its workers unevenly (3 to 8 at once): 200 ms each drains in 0.6-1.2 s. Its 8 + * workers cannot show the cap, so the channel's own count is checked on every pass. + */ + #[MaximumDuration(6000)] + public function testAtMostTenTransfersRunAtOnce(): void + { + $url = $this->collector()->scenario([], ['status' => 200, 'delayMs' => 200]); + $channel = $this->channel(); + for ($i = 0; $i < 12; ++$i) { + $channel->post(new HttpRequest($url . '/r' . $i, [], '{}'), static function (): void {}); + } + + $channel->pump(); + + $this->assertSame(12, $channel->pending()); + $this->assertSame(CurlHttpChannel::MAX_CONCURRENT, $channel->inTransfer()); + $until = microtime(true) + 5.0; + while ($channel->pending() > 0) { + $this->assertLessThan($until, microtime(true), 'the requests did not finish'); + $channel->waitForActivity(0.05); + $channel->pump(); + $this->assertLessThanOrEqual(CurlHttpChannel::MAX_CONCURRENT, $channel->inTransfer()); + } + $this->assertCount(12, $this->collector()->requests($url)); + } + + /** Ruling P11: a multi handle that cannot run fails its transfers, so a flush without a deadline cannot hang. */ + #[MaximumDuration(3000)] + public function testAFailingExecFailsEveryTransferInsteadOfWaitingForever(): void + { + $url = $this->collector()->scenario(); + $channel = $this->channel( + multiStatus: static fn(string $function, int $code): int => $function === 'curl_multi_exec' ? CURLM_INTERNAL_ERROR : $code, + ); + $errors = []; + $record = static function (HttpResponse $response) use (&$errors): void { + $errors[] = $response->error; + }; + $channel->post(new HttpRequest($url . '/a', [], '{}'), $record); + $channel->post(new HttpRequest($url . '/b', [], '{}'), $record); + + $this->assertTrue($this->drain($channel, 2.0)); + + $this->assertSame([self::MULTI_ERROR, self::MULTI_ERROR], $errors); + $this->assertSame([], $this->logger->messages()); + } + + #[MaximumDuration(3000)] + public function testAfterAFailedExecTheFailedTransfersNeverReachTheServer(): void + { + $url = $this->collector()->scenario(); + $execs = new \ArrayObject(); + $channel = $this->channel(multiStatus: static function (string $function, int $code) use ($execs): int { + if ($function !== 'curl_multi_exec') { + return $code; + } + $execs[] = $code; + + return count($execs) === 1 ? CURLM_INTERNAL_ERROR : $code; + }); + $errors = []; + $failed = static function (HttpResponse $response) use (&$errors): void { + $errors[] = $response->error; + }; + $requests = [\WeakReference::create($failed)]; + $channel->post(new HttpRequest($url . '/a', [], '{}'), $failed); + $channel->post(new HttpRequest($url . '/b', [], '{}'), $failed); + unset($failed); + + $channel->pump(); + $this->assertSame(0, $channel->pending()); + $channel->post(new HttpRequest($url . '/c', [], '{}'), static function (HttpResponse $response) use (&$errors): void { + $errors[] = $response->error; + }); + $this->assertTrue($this->drain($channel, 2.0)); + + $this->assertSame([self::MULTI_ERROR, self::MULTI_ERROR, null], $errors); + // One curl_multi_exec() opens the connections but sends nothing. The failed handles left the multi handle: + // they were never sent, and nothing holds their requests any more (either check alone loses a race). + $received = $this->collector()->requests($url); + $this->assertCount(1, $received); + $this->assertSame('/c', $received[0]->path); + $this->assertNull($requests[0]->get()); + } + + #[MaximumDuration(3000)] + public function testAFailedAddFailsThatRequestOnly(): void + { + $url = $this->collector()->scenario(); + $adds = new \ArrayObject(); + $channel = $this->channel(multiStatus: static function (string $function, int $code) use ($adds): int { + if ($function !== 'curl_multi_add_handle') { + return $code; + } + $adds[] = $code; + + return count($adds) === 1 ? CURLM_INTERNAL_ERROR : $code; + }); + $responses = []; + $refused = static function (HttpResponse $response) use (&$responses): void { + $responses[] = $response; + }; + $request = \WeakReference::create($refused); + $channel->post(new HttpRequest($url . '/refused', [], '{}'), $refused); + $channel->post(new HttpRequest($url . '/sent', [], '{}'), static function (HttpResponse $response) use (&$responses): void { + $responses[] = $response; + }); + unset($refused); + + $this->assertTrue($this->drain($channel, 2.0)); + + $this->assertCount(2, $responses); + $this->assertSame(self::MULTI_ERROR, $responses[0]->error); + $this->assertSame(202, $responses[1]->status); + // The refused handle left the multi handle again: it was never sent, and nothing holds its request any more. + $requests = $this->collector()->requests($url); + $this->assertCount(1, $requests); + $this->assertSame('/sent', $requests[0]->path); + $this->assertNull($request->get()); + } + + /** `$onDone` runs once for every request: one that curl refuses to set up fails through its callback too. */ + #[MaximumDuration(3000)] + public function testARequestCurlCannotSetUpFailsThroughItsCallback(): void + { + $url = $this->collector()->scenario(); + $channel = $this->channel(); + $responses = []; + $record = static function (HttpResponse $response) use (&$responses): void { + $responses[] = $response; + }; + $channel->post(new HttpRequest($url . "/nul\0one", [], '{}'), $record); + $channel->post(new HttpRequest($url . "/nul\0two", [], '{}'), $record); + $channel->post(new HttpRequest($url . '/next', [], '{}'), $record); + + $channel->pump(); + + $this->assertSame(1, $channel->inTransfer(), 'the next request started in the same pass'); + $this->assertTrue($this->drain($channel, 2.0)); + $this->assertCount(3, $responses); + $nul = 'ValueError: curl_setopt_array(): cURL option must not contain any null bytes'; + $this->assertSame([$nul, $nul, null], array_map(static fn(HttpResponse $response): ?string => $response->error, $responses)); + $this->assertSame(202, $responses[2]->status); + $this->assertCount(1, $this->collector()->requests($url)); + } + + /** + * M2: curl_setopt_array() stops at the first option it rejects (libcurl takes no URL over 8 MB). Such a request + * fails through its callback and is never added; the options that take the response come first, so nothing of + * any response reaches STDOUT (the stdio MCP stream; PHPUnit fails a test that prints). + */ + #[MaximumDuration(3000)] + public function testARequestWithARejectedOptionFailsAndPrintsNothing(): void + { + $url = $this->collector()->scenario([], ['status' => 200, 'body' => 'a response body']); + $channel = $this->channel(); + $responses = []; + $record = static function (HttpResponse $response) use (&$responses): void { + $responses[] = $response; + }; + $channel->post(new HttpRequest($url . '/' . str_repeat('a', 8_000_001), [], '{}'), $record); + $channel->post(new HttpRequest($url . '/next', [], '{}'), $record); + + $this->assertTrue($this->drain($channel, 2.0)); + + $this->assertCount(2, $responses); + $this->assertSame('cURL error: curl_setopt_array() failed', $responses[0]->error); + $this->assertSame('a response body', $responses[1]->body); + $requests = $this->collector()->requests($url); + $this->assertCount(1, $requests); + $this->assertSame('/next', $requests[0]->path); + } + + /** I2: a throwable inside a pump never reaches the caller; the seam sees it, and the next pump goes on. */ + #[MaximumDuration(3000)] + public function testAThrowableInAPumpIsSwallowedAndTheTransfersGoOn(): void + { + $url = $this->collector()->scenario(); + $execs = new \ArrayObject(); + $channel = $this->channel(multiStatus: static function (string $function, int $code) use ($execs): int { + if ($function === 'curl_multi_exec') { + $execs[] = $code; + if (count($execs) === 1) { + throw new \LogicException('a broken multi handle'); + } + } + + return $code; + }); + $statuses = []; + $channel->post(new HttpRequest($url . '/a', [], '{}'), static function (HttpResponse $response) use (&$statuses): void { + $statuses[] = $response->status; + }); + + $channel->pump(); + + $this->assertSame(['LogicException: a broken multi handle'], array_map(ErrorText::of(...), $this->swallowed)); + $this->swallowed = []; + $this->assertSame(1, $channel->inTransfer()); + $this->assertTrue($this->drain($channel, 2.0)); + $this->assertSame([202], $statuses); + $this->assertSame([], $this->logger->messages()); + } + + #[MaximumDuration(3000)] + public function testAThrowingCallbackDoesNotStopTheOthers(): void + { + $url = $this->collector()->scenario(); + $channel = $this->channel(); + $statuses = []; + $channel->post(new HttpRequest($url . '/a', [], '{}'), static function (): never { + throw new \RuntimeException('a broken callback'); + }); + $channel->post(new HttpRequest($url . '/b', [], '{}'), static function (HttpResponse $response) use (&$statuses): void { + $statuses[] = $response->status; + }); + + $this->assertTrue($this->drain($channel, 2.0)); + + $this->assertSame([202], $statuses); + $this->assertSame([], $this->logger->messages()); + $this->assertSame(['RuntimeException: a broken callback'], array_map(ErrorText::of(...), $this->swallowed)); + $this->swallowed = []; + } + + /** D32: a warning in a callback reaches neither the host's error handler nor `error_get_last()`. */ + #[MaximumDuration(3000)] + public function testAWarningInACallbackStaysInsideTheChannel(): void + { + $url = $this->collector()->scenario(); + $channel = $this->channel(); + $finished = new \ArrayObject(); + $channel->post(new HttpRequest($url . '/a', [], '{}'), static function () use ($finished): void { + trigger_error('exporter warning', E_USER_WARNING); + $finished[] = true; + }); + set_error_handler(static fn(int $no, string $str): never => throw new \ErrorException($str, 0, $no)); + + try { + error_clear_last(); + + $this->assertTrue($this->drain($channel, 2.0)); + + $this->assertCount(1, $finished, 'the callback ran to its end'); + $this->assertNull(error_get_last()); + + try { + trigger_error('x', E_USER_WARNING); + self::fail('the host error handler is no longer installed'); + } catch (\ErrorException $exception) { + $this->assertSame('x', $exception->getMessage()); + } + } finally { + restore_error_handler(); + } + } + + /** + * Ruling P7: only a wait with a transfer in progress reaches the NaN guard. Without the guard, PHP 8.4+ computes + * a NAN bound (`max(0.0, NAN)`), which curl_multi_select() rejects with a ValueError: tearDown() sees it swallowed. + * CurlSupportTest pins the bound itself. + */ + #[MaximumDuration(3000)] + public function testAWaitWithANanOrNegativeBoundReturnsAtOnceDuringATransfer(): void + { + $server = stream_socket_server('tcp://127.0.0.1:0', $errorCode, $errorMessage); + $this->assertNotFalse($server, 'no local server: ' . $errorMessage); + $address = stream_socket_get_name($server, false); + $this->assertIsString($address); + $channel = $this->channel(); + $errors = []; + $channel->post(new HttpRequest('http://' . $address . '/v1/logs', [], '{}'), static function (HttpResponse $response) use (&$errors): void { + $errors[] = $response->error; + }); + $channel->pump(); + $connection = stream_socket_accept($server, 2.0); + $this->assertNotFalse($connection); + stream_set_blocking($connection, false); + $received = ''; + $until = microtime(true) + 2.0; + while (!str_ends_with($received, "\r\n\r\n{}")) { // the whole request; the server never answers it + $this->assertLessThan($until, microtime(true), 'the request did not arrive: ' . $received); + $channel->pump(); + $channel->waitForActivity(0.05); + $received .= (string) fread($connection, 8192); + } + $this->assertSame(1, $channel->inTransfer()); + + foreach ([NAN, -1.0] as $seconds) { + $begin = hrtime(true); + $channel->waitForActivity($seconds); + $this->assertLessThan(0.5, (hrtime(true) - $begin) / 1e9); + } + $begin = hrtime(true); + $channel->waitForActivity(0.2); + $this->assertGreaterThanOrEqual(0.15, (hrtime(true) - $begin) / 1e9, 'a positive bound does block'); + + fclose($connection); + fclose($server); + $this->assertTrue($this->drain($channel, 2.0)); + $this->assertCount(1, $errors); + $this->assertMatchesRegularExpression('/\AcURL error \d+: /', (string) $errors[0]); + } + + /** + * @param (\Closure(string, int): int)|null $multiStatus + * @param (\Closure(string, CallbackFlag): void)|null $onCallback + */ + private function channel( + float $connectTimeoutSeconds = CurlTransport::CONNECT_TIMEOUT_SECONDS, + float $totalTimeoutSeconds = CurlTransport::TOTAL_TIMEOUT_SECONDS, + ?\Closure $multiStatus = null, + ?\Closure $onCallback = null, + ): CurlHttpChannel { + return new CurlHttpChannel($connectTimeoutSeconds, $totalTimeoutSeconds, multiStatus: $multiStatus, onSwallowed: function (\Throwable $error): void { + $this->swallowed[] = $error; + }, onCallback: $onCallback); + } + + private function drain(HttpChannel $channel, float $seconds): bool + { + $until = microtime(true) + $seconds; + while (true) { + $channel->pump(); + if ($channel->pending() === 0) { + return true; + } + if (microtime(true) >= $until) { + return false; + } + $channel->waitForActivity(0.05); + } + } + + private function header(\stdClass $request, string $name): ?string + { + $headers = $request->headers ?? null; + $this->assertInstanceOf(\stdClass::class, $headers); + foreach (get_object_vars($headers) as $key => $value) { + if (strcasecmp((string) $key, $name) === 0) { + $this->assertIsString($value); + + return $value; + } + } + + return null; + } + + private function collector(): MockCollector + { + $this->assertInstanceOf(MockCollector::class, self::$collector); + + return self::$collector; + } +} diff --git a/tests/Integration/Transport/CurlMultiTest.php b/tests/Integration/Transport/CurlMultiTest.php new file mode 100644 index 0000000..36e5e11 --- /dev/null +++ b/tests/Integration/Transport/CurlMultiTest.php @@ -0,0 +1,145 @@ +stop(); + self::$collector = null; + } + + /** + * I2 and the N1 ruling: a signal handler runs between two opcodes, so also inside a PHP write callback of + * `curl_multi_exec()`. There libcurl answers every multi call with `CURLM_RECURSIVE_API_CALL`, and the engine must + * leave every handle and transfer as it is: removing one there frees a handle that libcurl still uses (a segfault). + * A refused add waits for the next `run()`. The test keeps every handle alive, so that a regression fails an + * assertion instead of crashing PHPUnit. + */ + #[MaximumDuration(5000)] + public function testACallFromInsideATransferCallbackLeavesEveryTransferAsItIs(): void + { + $url = $this->collector()->scenario([], ['status' => 200, 'body' => str_repeat('x', 200_000)]); + $codes = []; + /** @var CurlMulti $multi */ + $multi = new CurlMulti(static function (string $function, int $code) use (&$codes): int { + $codes[] = [$function, $code]; + + return $code; + }); + $finished = []; + $failed = []; + $onFinished = static function (string $payload, \CurlHandle $handle, int $result) use (&$finished): void { + $finished[] = [$payload, $result]; + }; + $onFailed = static function (string $payload, string $error) use (&$failed): void { + $failed[] = [$payload, $error]; + }; + $handles = []; + $nested = []; + $write = static function (\CurlHandle $handle, string $data) use ($multi, $url, $onFinished, $onFailed, &$handles, &$nested): int { + $handles[spl_object_id($handle)] = $handle; + if ($nested === []) { + $begin = hrtime(true); + $nested['run'] = $multi->run($onFinished, $onFailed); + $multi->select(3.0); + $nested['open'] = $multi->open([CURLOPT_URL => $url . '/b', CURLOPT_RETURNTRANSFER => true], 'b'); + $nested['seconds'] = (hrtime(true) - $begin) / 1e9; + $nested['count'] = $multi->count(); + } + + return strlen($data); + }; + + $this->assertNull($multi->open([CURLOPT_URL => $url . '/a', CURLOPT_WRITEFUNCTION => $write], 'a')); + $this->drain($multi, $onFinished, $onFailed); + + $this->assertSame([], $failed, 'no transfer failed'); + sort($finished); + $this->assertSame([['a', CURLE_OK], ['b', CURLE_OK]], $finished, 'both completed, once each'); + $this->assertSame(['run', 'open', 'seconds', 'count'], array_keys($nested), 'the write callback ran'); + $this->assertFalse($nested['run'] ?? null, 'run() reports the refusal'); + $this->assertArrayHasKey('open', $nested); + $this->assertNull($nested['open'], 'the add from inside the callback waits, it does not fail'); + $this->assertLessThan(1.5, $nested['seconds'] ?? INF, 'libcurl refused the nested wait at once'); + $this->assertSame(2, $nested['count'] ?? null, 'a stayed, b waits'); + $this->assertContains(['curl_multi_exec', CurlMulti::RECURSIVE_API_CALL], $codes, 'the nested run was refused'); + $this->assertContains(['curl_multi_add_handle', CurlMulti::RECURSIVE_API_CALL], $codes, 'so was the nested add'); + $this->assertCount(2, array_keys($codes, ['curl_multi_add_handle', CURLM_OK], true), 'the next run added b'); + $this->assertSame(0, $multi->count()); + } + + /** N1: PHP runs a signal that arrived during curl_multi_exec() right after it returns; a call from there works as usual. */ + #[MaximumDuration(5000)] + public function testACallRightAfterCurlMultiExecReturnsWorksAsUsual(): void + { + $url = $this->collector()->scenario(); + $finished = []; + $onFinished = static function (string $payload, \CurlHandle $handle, int $result) use (&$finished): void { + $finished[] = [$payload, $result]; + }; + $onFailed = static function (string $payload, string $error): never { + self::fail('no transfer may fail: ' . $error); + }; + /** @var CurlMulti|null $multi */ + $multi = null; + $nested = []; + $multi = new CurlMulti(static function (string $function, int $code) use (&$multi, &$nested, $onFinished, $onFailed): int { + if ($function === 'curl_multi_exec' && $nested === [] && $multi instanceof CurlMulti) { + $nested[] = $code; + $multi->select(0.5); + $nested[] = $multi->run($onFinished, $onFailed); // the seam runs where the signal would + $nested[] = $multi->count(); + } + + return $code; + }); + + $this->assertNull($multi->open([CURLOPT_URL => $url . '/a', CURLOPT_RETURNTRANSFER => true], 'a')); + $this->drain($multi, $onFinished, $onFailed); + + $this->assertSame([['a', CURLE_OK]], $finished); + $this->assertSame(CURLM_OK, $nested[0] ?? null, 'the outer curl_multi_exec() had returned'); + $this->assertTrue($nested[1] ?? null, 'so the nested run ran'); + } + + /** + * @param CurlMulti $multi + * @param \Closure(string, \CurlHandle, int): void $finished + * @param \Closure(string, string): void $failed + */ + private function drain(CurlMulti $multi, \Closure $finished, \Closure $failed): void + { + $until = microtime(true) + 3.0; + while ($multi->count() > 0) { + $this->assertLessThan($until, microtime(true), 'the transfers did not finish'); + $multi->run($finished, $failed); + $multi->select(0.05); + } + } + + private function collector(): MockCollector + { + $this->assertInstanceOf(MockCollector::class, self::$collector); + + return self::$collector; + } +} diff --git a/tests/Integration/Transport/CurlTransportTest.php b/tests/Integration/Transport/CurlTransportTest.php index 05a209f..affe71e 100644 --- a/tests/Integration/Transport/CurlTransportTest.php +++ b/tests/Integration/Transport/CurlTransportTest.php @@ -16,6 +16,7 @@ use AgentCat\Internal\Wire\WireEncoder; use AgentCat\Tests\Integration\Support\MockCollector; use AgentCat\Tests\Support\MemoryLogger; +use AgentCat\Tests\Support\Thrown; use Ergebnis\PHPUnit\SlowTestDetector\Attribute\MaximumDuration; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; @@ -247,6 +248,53 @@ public function testConnectionRefusedIsRetried(): void $this->assertStringStartsWith(self::GAVE_UP . 'cURL error 7: Failed to connect to 127.0.0.1 port ', $messages[4]); } + /** + * curl_setopt_array() stops at the first option it rejects (libcurl takes no URL over 8 MB): the attempt fails + * and is retried; the handle is never added, so no response can reach STDOUT (PHPUnit fails a test that prints). + */ + #[MaximumDuration(2000)] + public function testARejectedCurlOptionFailsTheAttempt(): void + { + $base = self::collector()->scenario(); + $event = $this->event(); + $delivery = Delivery::forEvent($event, ApiBaseUrl::endpoint($base) . str_repeat('a', 8_000_001), WireEncoder::encode($event)); + + $this->assertTrue($this->fast()->send($delivery, $this->deadline())); + + $failed = self::FAILED . 'cURL error: curl_setopt_array() failed]'; + $this->assertSame([$failed, $failed, $failed, $failed, self::GAVE_UP . 'cURL error: curl_setopt_array() failed'], $this->logger->messages()); + $this->assertSame([], self::collector()->requests($base)); + } + + /** The option that captures the response comes first: a later rejected option cannot leave it unset. */ + public function testTheResponseIsCapturedBeforeAnyOtherOption(): void + { + $options = (new CurlTransport())->curlOptions(new Delivery('https://x.test/v1/public/events/', '{}', 'evt_x', 'summary')); + + $this->assertSame(CURLOPT_RETURNTRANSFER, array_key_first($options)); + } + + /** @return iterable */ + public static function badTimeouts(): iterable + { + yield 'connect NAN' => [NAN, 10.0]; + yield 'connect zero (curl: no timeout)' => [0.0, 10.0]; + yield 'total negative' => [2.0, -1.0]; + yield 'total INF' => [2.0, INF]; + } + + /** A timeout curl cannot take is a programming error; the queue builds the transport with the defaults. */ + #[DataProvider('badTimeouts')] + public function testATimeoutCurlCannotTakeIsRejectedAtConstruction(float $connectTimeoutSeconds, float $totalTimeoutSeconds): void + { + $error = Thrown::by( + \InvalidArgumentException::class, + static fn(): CurlTransport => new CurlTransport(connectTimeoutSeconds: $connectTimeoutSeconds, totalTimeoutSeconds: $totalTimeoutSeconds), + ); + + $this->assertStringStartsWith('A curl timeout must round to 1 through 2147483647 milliseconds, got ', $error->getMessage()); + } + #[MaximumDuration(2000)] public function testOnlyHttpAndHttpsAreAllowed(): void { @@ -942,6 +990,24 @@ public function testTheFunctionsAreCheckedOnceAtTheFirstDelivery(): void $this->assertCount(2, self::collector()->requests($base)); } + /** `start()` starts the attempt: its first curl_multi_exec() runs at once, not at the next pump. */ + #[MaximumDuration(1000)] + public function testStartRunsTheFirstTransferStep(): void + { + $base = self::collector()->scenario(); + $calls = new \ArrayObject(); + $transport = new CurlTransport(multiStatus: static function (string $function, int $code) use ($calls): int { + $calls[] = $function; + + return $code; + }); + + $transport->start($this->delivery($base)); + + $this->assertSame(['curl_multi_add_handle', 'curl_multi_exec'], $calls->getArrayCopy()); + $this->assertTrue($transport->drain($this->deadline())); // leave no request behind in the collector + } + public function testPumpBeforeTheFirstDeliveryRunsNoCurl(): void { $execs = new \ArrayObject(); diff --git a/tests/Support/ExporterFixtures.php b/tests/Support/ExporterFixtures.php new file mode 100644 index 0000000..f74bb99 --- /dev/null +++ b/tests/Support/ExporterFixtures.php @@ -0,0 +1,73 @@ + 'prod', 'Bad Key:x' => 'a,b'], + properties: Json::decode('{"device":"desktop"}'), + ); + } + + public static function errorEvent(): Event + { + $event = self::event(); + $event->isError = true; + $event->error = Json::decode('{"message":"boom","type":"TypeError","stack":"TypeError: boom\n at x (/a.js:1:1)"}'); + + return $event; + } + + public static function customEvent(): Event + { + $event = self::event(); + $event->eventType = 'agentcat:custom'; + $event->resourceName = null; + $event->sessionId = ''; + $event->identifyActorGivenId = null; + $event->identifyActorName = null; + $event->identifyActorData = null; + $event->tags = null; + $event->properties = null; + $event->duration = null; + + return $event; + } + + public static function listEvent(): Event + { + return new Event(id: self::EVENT, sessionId: '', projectId: '', eventType: 'mcp:tools/list', timestamp: new \DateTimeImmutable('2025-06-15T12:00:05.123Z'), duration: 0, serverName: 's'); + } +} diff --git a/tests/Support/ExporterGolden.php b/tests/Support/ExporterGolden.php new file mode 100644 index 0000000..733b2bf --- /dev/null +++ b/tests/Support/ExporterGolden.php @@ -0,0 +1,172 @@ + value, from the lines before the first `=== ` section. + * + * @return array{sessionId: string, eventId: string, traceId: string, spanId: string, ddTrace: string, ddSpan: string, 'uuid(session)': string, 'uuid(event)': string, 'uuid(my-customer-task-id)': string, "uuid('')": string, 'uuid(ses_session45600000000000000000)': string, 'uuid(abc_def_ghi)': string, 'uuid(27 dashes)': string} + */ + public static function ids(): array + { + $header = strstr(Golden::text('exporters/golden-output.txt'), '=== ', true); + preg_match_all("/(sessionId|eventId|traceId|spanId|ddTrace|ddSpan|uuid\\([^)]*\\)) (\\S+)/", (string) $header, $matches, PREG_SET_ORDER); + $ids = []; + foreach ($matches as $match) { + $ids[$match[1]] = $match[2]; + } + + return [ + 'sessionId' => self::value($ids, 'sessionId'), + 'eventId' => self::value($ids, 'eventId'), + 'traceId' => self::value($ids, 'traceId'), + 'spanId' => self::value($ids, 'spanId'), + 'ddTrace' => self::value($ids, 'ddTrace'), + 'ddSpan' => self::value($ids, 'ddSpan'), + 'uuid(session)' => self::value($ids, 'uuid(session)'), + 'uuid(event)' => self::value($ids, 'uuid(event)'), + 'uuid(my-customer-task-id)' => self::value($ids, 'uuid(my-customer-task-id)'), + "uuid('')" => self::value($ids, "uuid('')"), + 'uuid(ses_session45600000000000000000)' => self::value($ids, 'uuid(ses_session45600000000000000000)'), + 'uuid(abc_def_ghi)' => self::value($ids, 'uuid(abc_def_ghi)'), + 'uuid(27 dashes)' => self::value($ids, 'uuid(27 dashes)'), + ]; + } + + /** @param array $ids */ + private static function value(array $ids, string $label): string + { + return $ids[$label] ?? throw new \RuntimeException('The golden header has no ' . $label . ' line'); + } + + /** @return list */ + public static function requests(string $section): array + { + $requests = []; + $current = null; + foreach (self::section('exporters/golden-output.txt', $section) as $line) { + if (str_starts_with($line, 'URL POST ')) { + if ($current !== null) { + $requests[] = $current; + } + $current = ['url' => substr($line, 9), 'headers' => new \stdClass(), 'body' => '']; + } elseif ($current !== null && str_starts_with($line, 'HEADERS ')) { + $headers = Json::decode(substr($line, 8)); + $current['headers'] = $headers instanceof \stdClass ? $headers : new \stdClass(); + } elseif ($current !== null && str_starts_with($line, 'BODY ')) { + $current['body'] = substr($line, 5); + } elseif ($current !== null) { + $current['body'] .= "\n" . $line; + } + } + if ($current !== null) { + $requests[] = $current; + } + + return $requests; + } + + /** @return list */ + public static function requests2(string $section): array + { + $requests = []; + $lines = self::section('exporters/golden2-output.txt', $section); + foreach ($lines as $index => $line) { + if (preg_match('~\Ahttps?://\S+ \z~', $line) !== 1) { + continue; + } + $body = [substr($lines[$index + 1] ?? ' ', 1)]; + for ($next = $index + 2; isset($lines[$next]) && preg_match('~\A(https?://|=== |OTLP )~', $lines[$next]) !== 1; ++$next) { + $body[] = $lines[$next]; + } + $requests[] = ['url' => rtrim($line), 'body' => implode("\n", $body)]; + } + + return $requests; + } + + /** + * The `=== diagnostics` section of `golden2-output.txt`: its first line is ` `, the rest is the + * pretty-printed body. + * + * @return array{url: string, headers: \stdClass, body: \stdClass} + */ + public static function diagnostics(): array + { + $lines = self::section('exporters/golden2-output.txt', 'diagnostics'); + [$url, $headers] = explode(' ', array_shift($lines) ?? '', 2) + ['', '']; + $headers = Json::decode(trim($headers)); + $body = Json::decode(implode("\n", $lines)); + if (!$headers instanceof \stdClass || !$body instanceof \stdClass) { + throw new \RuntimeException('The golden diagnostics section has no headers or no body'); + } + + return ['url' => $url, 'headers' => $headers, 'body' => $body]; + } + + /** Decoded and re-encoded: escaping no longer differs, key order stays. */ + public static function normalized(string $json): string + { + return Json::encodeJs(Json::decode($json)) ?? 'unencodable'; + } + + /** + * A Sentry envelope with the volatile parts fixed: every JSON line decoded and re-encoded, `sent_at` is `X`, and in + * transaction and error envelopes every `event_id` keeps its first 16 hex digits (the rest is random) and ends in `R`. + */ + public static function maskSentry(string $envelope): string + { + $lines = explode("\n", $envelope); + $item = Json::decode($lines[1] ?? '{}'); + $random = $item instanceof \stdClass && in_array($item->type ?? null, ['transaction', 'event'], true); + foreach ($lines as $index => $line) { + if ($line === '') { + continue; + } + $decoded = Json::decode($line); + if ($decoded instanceof \stdClass) { + if (isset($decoded->sent_at)) { + $decoded->sent_at = 'X'; + } + if ($random && is_string($decoded->event_id ?? null)) { + $decoded->event_id = substr($decoded->event_id, 0, 16) . 'R'; + } + } + $lines[$index] = Json::encodeJs($decoded) ?? 'unencodable'; + } + + return implode("\n", $lines); + } + + /** @return list the lines of one `=== ` section, up to the next section or a TS error line */ + private static function section(string $file, string $name): array + { + $lines = []; + $inside = false; + foreach (explode("\n", Golden::text($file)) as $line) { + if (str_starts_with($line, '=== ')) { + if ($inside) { + break; + } + $inside = $line === '=== ' . $name; + + continue; + } + if ($inside && preg_match('/\A(sentry uppercase key|sentry nonnumeric project|OTLP fractional duration|OTLP 404):/', $line) === 1) { + break; + } + if ($inside) { + $lines[] = $line; + } + } + + return $lines; + } +} diff --git a/tests/Support/RecordingCompanion.php b/tests/Support/RecordingCompanion.php new file mode 100644 index 0000000..1d18c4b --- /dev/null +++ b/tests/Support/RecordingCompanion.php @@ -0,0 +1,46 @@ + */ + public array $calls = []; + + public bool $work = false; + + /** @param (\Closure(): void)|null $onFinish */ + public function __construct( + private readonly ?\Closure $onFinish = null, + private readonly ?\Throwable $tickFailure = null, + ) {} + + #[\Override] + public function tick(): void + { + $this->calls[] = 'tick'; + if ($this->tickFailure instanceof \Throwable) { + throw $this->tickFailure; + } + } + + #[\Override] + public function finish(): void + { + $this->calls[] = 'finish'; + if ($this->onFinish instanceof \Closure) { + ($this->onFinish)(); + } + } + + #[\Override] + public function hasWork(): bool + { + return $this->work; + } +} diff --git a/tests/Support/RecordingHttpChannel.php b/tests/Support/RecordingHttpChannel.php new file mode 100644 index 0000000..855a463 --- /dev/null +++ b/tests/Support/RecordingHttpChannel.php @@ -0,0 +1,81 @@ + */ + public array $requests = []; + + /** @var list the `$seconds` of every waitForActivity() call */ + public array $waits = []; + + /** @var list */ + private array $held = []; + + private readonly HttpResponse $default; + + /** + * @param list $responses the answers to requests 0, 1, 2, … + * @param (\Closure(float): void)|null $onWait runs in waitForActivity() with its seconds (a test can advance a clock) + */ + public function __construct( + private readonly array $responses = [], + ?HttpResponse $default = null, + public bool $complete = true, + private readonly ?\Closure $onWait = null, + ) { + $this->default = $default ?? new HttpResponse(200, 'OK', 'ok'); + } + + #[\Override] + public function post(HttpRequest $request, \Closure $onDone): void + { + $this->requests[] = $request; + $this->held[] = ['index' => count($this->requests) - 1, 'onDone' => $onDone, 'background' => $request->background]; + } + + #[\Override] + public function pump(): void + { + while ($this->complete && $this->held !== []) { + $next = array_shift($this->held); + ($next['onDone'])($this->responses[$next['index']] ?? $this->default); + } + } + + #[\Override] + public function pending(bool $includeBackground = true): int + { + $pending = 0; + foreach ($this->held as $held) { + if ($includeBackground || !$held['background']) { + ++$pending; + } + } + + return $pending; + } + + #[\Override] + public function waitForActivity(float $seconds): void + { + $this->waits[] = $seconds; + if ($this->onWait instanceof \Closure) { + ($this->onWait)($seconds); + } + } + + /** @return list the URLs, in post order */ + public function urls(): array + { + return array_map(static fn(HttpRequest $request): string => $request->url, $this->requests); + } +} diff --git a/tests/Support/Samples/MissingSleepProperty.php b/tests/Support/Samples/MissingSleepProperty.php new file mode 100644 index 0000000..41d295d --- /dev/null +++ b/tests/Support/Samples/MissingSleepProperty.php @@ -0,0 +1,15 @@ + */ + public function __sleep(): array + { + return ['missing']; + } +} diff --git a/tests/Unit/Exporters/ExporterClassesTest.php b/tests/Unit/Exporters/ExporterClassesTest.php new file mode 100644 index 0000000..6b13aea --- /dev/null +++ b/tests/Unit/Exporters/ExporterClassesTest.php @@ -0,0 +1,79 @@ + 'y']); + + $this->assertSame('http://h', $exporter->endpoint); + $this->assertSame(['x' => 'y'], $exporter->headers); + $this->assertSame([], (new OtlpExporter('http://h'))->headers); + } + + public function testDatadogKeepsItsNamedArgumentsAndDefaults(): void + { + $exporter = new DatadogExporter(apiKey: 'k', site: 'datadoghq.eu', service: 'svc', env: 'prod'); + + $this->assertSame(['k', 'datadoghq.eu', 'svc', 'prod'], [$exporter->apiKey, $exporter->site, $exporter->service, $exporter->env]); + $this->assertNull((new DatadogExporter('k', 's', 'v'))->env); + } + + public function testSentryKeepsItsNamedArgumentsAndDefaults(): void + { + $exporter = new SentryExporter(dsn: 'd', environment: 'e', release: 'r', enableTracing: true); + + $this->assertSame(['d', 'e', 'r', true], [$exporter->dsn, $exporter->environment, $exporter->release, $exporter->enableTracing]); + + $default = new SentryExporter('d'); + $this->assertNull($default->environment); + $this->assertNull($default->release); + $this->assertFalse($default->enableTracing); + } + + public function testPostHogKeepsItsNamedArgumentsAndDefaults(): void + { + $exporter = new PostHogExporter(apiKey: 'k', host: 'https://h', enableAITracing: true); + + $this->assertSame(['k', 'https://h', true], [$exporter->apiKey, $exporter->host, $exporter->enableAITracing]); + + $default = new PostHogExporter('k'); + $this->assertNull($default->host); + $this->assertFalse($default->enableAITracing); + } + + /** @param class-string $class */ + #[DataProvider('classes')] + public function testEveryClassIsFinalAndReadonly(string $class): void + { + $reflection = new \ReflectionClass($class); + + $this->assertTrue($reflection->isFinal()); + $this->assertTrue($reflection->isReadOnly()); + } + + /** @return \Iterator */ + public static function classes(): \Iterator + { + yield 'otlp' => [OtlpExporter::class]; + yield 'datadog' => [DatadogExporter::class]; + yield 'sentry' => [SentryExporter::class]; + yield 'posthog' => [PostHogExporter::class]; + } + + public function testAnInvalidDsnConstructsWithoutAnError(): void + { + $this->assertSame('not a dsn', (new SentryExporter('not a dsn'))->dsn); + } +} diff --git a/tests/Unit/Internal/Delivery/EventQueueTest.php b/tests/Unit/Internal/Delivery/EventQueueTest.php index e996dcd..b9b2700 100644 --- a/tests/Unit/Internal/Delivery/EventQueueTest.php +++ b/tests/Unit/Internal/Delivery/EventQueueTest.php @@ -7,19 +7,28 @@ use AgentCat\Event; use AgentCat\Internal\Delivery\EventQueue; use AgentCat\Internal\Delivery\ExporterSink; +use AgentCat\Internal\Delivery\SignalDrain; use AgentCat\Internal\Json\Json; use AgentCat\Internal\Logging\Log; use AgentCat\Internal\Pipeline\EventProcessor; use AgentCat\Internal\Pipeline\QueuedEvent; use AgentCat\Internal\Pipeline\Sanitizer; +use AgentCat\Internal\Transport\CurlHttpChannel; use AgentCat\Internal\Transport\Delivery; +use AgentCat\Internal\Transport\HttpChannel; +use AgentCat\Internal\Transport\HttpRequest; +use AgentCat\Internal\Transport\HttpResponse; use AgentCat\Internal\Wire\WireEncoder; use AgentCat\Tests\Support\JsonAssert; use AgentCat\Tests\Support\MemoryLogger; +use AgentCat\Tests\Support\RecordingCompanion; use AgentCat\Tests\Support\RecordingExporter; +use AgentCat\Tests\Support\RecordingHttpChannel; use AgentCat\Tests\Support\RecordingTransport; use Ergebnis\PHPUnit\SlowTestDetector\Attribute\MaximumDuration; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\RequiresPhpExtension; use PHPUnit\Framework\TestCase; use Psr\Log\LogLevel; @@ -723,6 +732,513 @@ public function testStatsReportsWaitingAndInFlight(): void $this->assertSame(['queueLength' => 1, 'activeRequests' => 5], $queue->stats()); } + #[Group('ac-06-63')] + public function testFlushWaitsForTheSideChannelAndRunsItsCallbacks(): void + { + $channel = new RecordingHttpChannel(); + $answered = []; + $queue = new EventQueue(transport: new RecordingTransport(), exporters: $this->posting($channel, static function (HttpResponse $r) use (&$answered): void { + $answered[] = $r->status; + }), channel: $channel); + $queue->add(new QueuedEvent(new Event(eventType: 'mcp:tools/call', resourceName: 'a'))); + + $this->assertTrue($queue->flush()); + + $this->assertSame(['http://exporter.test/a'], $channel->urls()); + $this->assertSame([200], $answered); + } + + #[Group('ac-06-63')] + public function testFlushReportsFalseWhileASideRequestIsStillPending(): void + { + $channel = new RecordingHttpChannel(complete: false); + $queue = new EventQueue(transport: new RecordingTransport(), exporters: $this->posting($channel), channel: $channel); + $queue->add(new QueuedEvent(new Event(eventType: 'mcp:tools/call', resourceName: 'a'))); + + $this->assertFalse($queue->flush(1.0)); + $this->assertSame(1, $channel->pending()); + $this->assertTrue($queue->hasWork()); + } + + /** I1: AgentCat's own requests (diagnostics) never hold `flush()` up or make it false; `shutdown()` sends them. */ + #[Group('ac-06-63')] + public function testFlushNeitherCountsNorWaitsForABackgroundRequest(): void + { + $channel = new RecordingHttpChannel(complete: false); + $queue = new EventQueue(transport: new RecordingTransport(), channel: $channel); + $channel->post(new HttpRequest('http://collector.test/v1/logs', [], '{}', background: true), static function (): void {}); + + $this->assertTrue($queue->flush(1.0)); + + $this->assertSame([], $channel->waits, 'flush() did not wait for it'); + $this->assertSame(1, $channel->pending()); + $this->assertTrue($queue->hasWork(), 'it is still there for shutdown()'); + } + + #[Group('ac-06-63')] + public function testFlushStillWaitsForAnExporterRequestBesideABackgroundOne(): void + { + $channel = new RecordingHttpChannel(complete: false); + $queue = new EventQueue(transport: new RecordingTransport(), channel: $channel); + $channel->post(new HttpRequest('http://collector.test/v1/logs', [], '{}', background: true), static function (): void {}); + $channel->post(new HttpRequest('http://exporter.test/a', [], '{}'), static function (): void {}); + + $this->assertFalse($queue->flush(1.0)); + $this->assertNotSame([], $channel->waits); + } + + #[Group('ac-06-63')] + public function testShutdownWaitsForABackgroundRequestInsideItsBudget(): void + { + $answered = []; + $channel = new RecordingHttpChannel(complete: false, onWait: static function () use (&$channel): void { + if ($channel instanceof RecordingHttpChannel) { + $channel->complete = true; // the collector answers during the first wait + } + }); + $queue = new EventQueue(transport: new RecordingTransport(), channel: $channel); + $channel->post(new HttpRequest('http://collector.test/v1/logs', [], '{}', background: true), static function () use (&$answered): void { + $answered[] = 'diagnostics'; + }); + + $queue->shutdown(); + + $this->assertSame(['diagnostics'], $answered); + $this->assertCount(1, $channel->waits); + } + + /** The last pass after `finish()` waits for the background request that `finish()` posted. */ + #[Group('ac-06-63')] + public function testShutdownWaitsForTheBackgroundRequestOfAFinishingCompanion(): void + { + $answered = []; + $channel = new RecordingHttpChannel(complete: false, onWait: static function () use (&$channel): void { + if ($channel instanceof RecordingHttpChannel) { + $channel->complete = true; + } + }); + $queue = new EventQueue(transport: new RecordingTransport(), channel: $channel); + $queue->attach(new RecordingCompanion(static function () use ($channel, &$answered): void { + $channel->post(new HttpRequest('http://collector.test/v1/logs', [], '{}', background: true), static function () use (&$answered): void { + $answered[] = 'diagnostics'; + }); + })); + + $queue->shutdown(); + + $this->assertSame(['diagnostics'], $answered); + } + + /** + * @return iterable, list}> the timeout, then where each transport slice ends + * and how long each channel slice is + */ + public static function sliceDeadlines(): iterable + { + yield 'the channel slice is cut to the time left' => [0.035, [0.01, 0.03], [0.01, 0.005]]; + yield 'the transport slice ends at the deadline, the channel gets none' => [0.045, [0.01, 0.03, 0.045], [0.01, 0.01, 0.0]]; + } + + /** + * Neither side waits on the other's sockets: up to 10 ms on the transport, then up to 10 ms on the channel, and + * neither slice ends past the deadline. The channel double advances the recording clock by its wait. + * + * @param list $transportEnds + * @param list $channelSlices + */ + #[Group('ac-06-63')] + #[DataProvider('sliceDeadlines')] + public function testFlushWaitsInShortSlicesThatNeverEndPastTheDeadline(float $timeout, array $transportEnds, array $channelSlices): void + { + $transport = new RecordingTransport(); + $ends = ['transport' => [], 'channel' => []]; + $channel = new RecordingHttpChannel(complete: false, onWait: static function (float $seconds) use ($transport, &$ends): void { + $ends['transport'][] = $transport->now(); + $transport->waitForActivity($transport->now() + $seconds); + $ends['channel'][] = $transport->now(); + }); + $queue = new EventQueue(transport: $transport, channel: $channel); + $channel->post(new HttpRequest('http://exporter.test/a', [], '{}'), static function (): void {}); + + $this->assertFalse($queue->flush($timeout)); + + $this->assertEqualsWithDelta($transportEnds, $ends['transport'], 1e-12); + $this->assertEqualsWithDelta($channelSlices, $channel->waits, 1e-12); + $this->assertEqualsWithDelta($timeout, $transport->now(), 1e-12, 'the last slice ends at the deadline'); + foreach ($ends['channel'] as $end) { + $this->assertLessThanOrEqual($timeout + 1e-12, $end, 'no slice ends past the deadline'); + } + } + + #[Group('ac-06-63')] + public function testShutdownFinishesTheCompanionsAfterTheEventsAndDrivesTheirRequests(): void + { + $channel = new RecordingHttpChannel(); + $order = []; + $queue = new EventQueue(transport: new RecordingTransport(), exporters: $this->posting($channel, static function () use (&$order): void { + $order[] = 'export answered'; + }), channel: $channel); + $queue->attach(new RecordingCompanion(static function () use ($channel, &$order): void { + $order[] = 'finish'; + $channel->post(new HttpRequest('http://collector.test/v1/logs', [], '{}'), static function () use (&$order): void { + $order[] = 'diagnostics answered'; + }); + })); + $queue->add(new QueuedEvent(new Event(projectId: 'proj_x', eventType: 'mcp:tools/call', resourceName: 'a'))); + + $queue->shutdown(); + + $this->assertSame(['export answered', 'finish', 'diagnostics answered'], $order); + } + + public function testPumpTicksTheCompanionsAndPumpsTheSideChannel(): void + { + $channel = new RecordingHttpChannel(); + $queue = new EventQueue(transport: new RecordingTransport(), channel: $channel); + $companion = new RecordingCompanion(); + $queue->attach($companion); + $answered = 0; + $channel->post(new HttpRequest('http://exporter.test/a', [], '{}'), static function () use (&$answered): void { + ++$answered; + }); + + $queue->pump(); + + $this->assertSame(['tick'], $companion->calls); + $this->assertSame(1, $answered); + $this->assertSame(0, $channel->pending()); + } + + public function testAThrowingCompanionNeverStopsThePass(): void + { + $channel = new RecordingHttpChannel(); + $queue = new EventQueue(transport: new RecordingTransport(), channel: $channel); + $failing = new RecordingCompanion(tickFailure: new \RuntimeException('x')); + $plain = new RecordingCompanion(); + $queue->attach($failing); + $queue->attach($plain); + $answered = 0; + $channel->post(new HttpRequest('http://exporter.test/a', [], '{}'), static function () use (&$answered): void { + ++$answered; + }); + + $queue->pump(); + + $this->assertSame(['tick'], $failing->calls); + $this->assertSame(['tick'], $plain->calls); + $this->assertSame(1, $answered, 'the side channel still runs'); + $this->assertSame([], $this->logger->messages()); + } + + public function testAThrowingSideChannelNeverStopsTheQueue(): void + { + $transport = new RecordingTransport(); + $channel = new RecordingHttpChannel(); + $queue = new EventQueue(transport: $transport, channel: $channel); + $channel->post(new HttpRequest('http://exporter.test/a', [], '{}'), static fn(): never => throw new \RuntimeException('callback')); + $queue->add(new QueuedEvent($this->event())); + + $queue->pump(); + + $this->assertCount(1, $transport->deliveries); + $this->assertSame([], $this->logger->messages()); + $this->assertTrue($queue->flush()); + } + + /** The C2 setup of a shutdown after the shared deadline: the companions finished in the first one's reserve. */ + #[Group('ac-03-9')] + public function testALaterShutdownAfterTheSharedDeadlineFinishesNothing(): void + { + $transport = new RecordingTransport(complete: false); + $queue = new EventQueue(transport: $transport, shutdownTimeoutSeconds: 1.0); + $companion = new RecordingCompanion(); + $queue->attach($companion); + $queue->add(new QueuedEvent($this->event())); + $queue->shutdown(); // the held event uses the budget up to the reserve, then the companion finishes + + $queue->shutdown(); + + $this->assertSame(['tick', 'tick', 'finish'], $companion->calls, 'nothing after the deadline'); + $this->assertEqualsWithDelta(1.0, $transport->now(), PHP_FLOAT_EPSILON); + $this->assertSame(2, $transport->waits); + } + + /** @return iterable the shutdown timeout and when `finish()` runs */ + public static function finishReserves(): iterable + { + yield '10 % of 1 s' => [1.0, 0.9]; + yield '10 % of 2 s' => [2.0, 1.8]; + yield 'at most 0.5 s of the default 5 s' => [EventQueue::SHUTDOWN_TIMEOUT_SECONDS, 4.5]; + yield 'at most 0.5 s of 10 s' => [10.0, 9.5]; + } + + /** + * A slow AgentCat API (or a hung exporter) never keeps the companions from finishing: their requests get the end of + * the budget, and the shutdown still ends at its deadline. The channel double advances the recording clock. + */ + #[Group('ac-03-9')] + #[Group('ac-06-63')] + #[DataProvider('finishReserves')] + public function testWithCompanionsTheEndOfTheBudgetIsKeptForFinish(float $timeout, float $finishAt): void + { + $transport = new RecordingTransport(complete: false); + $channel = new RecordingHttpChannel(complete: false, onWait: static function (float $seconds) use ($transport): void { + $transport->waitForActivity($transport->now() + $seconds); + }); + $queue = new EventQueue(transport: $transport, shutdownTimeoutSeconds: $timeout, channel: $channel); + $finishedAt = []; + $queue->attach(new RecordingCompanion(static function () use ($transport, $channel, &$finishedAt): void { + $finishedAt[] = $transport->now(); + $channel->post(new HttpRequest('http://collector.test/v1/logs', [], '{}'), static function (): void {}); + })); + $queue->add(new QueuedEvent($this->event())); // never completes + + $queue->shutdown(); + + $this->assertCount(1, $finishedAt); + $this->assertEqualsWithDelta($finishAt, $finishedAt[0], 1e-9, 'finish() runs when only the reserve is left'); + $this->assertNotSame([], $channel->waits, 'the last pass waits for the request'); + $this->assertEqualsWithDelta($timeout, $transport->now(), 1e-9, 'the last pass ends at the deadline'); + $this->assertSame(1, $channel->pending()); + $this->assertSame([], $this->logger->messages()); + } + + /** @return iterable */ + public static function noBudget(): iterable + { + yield 'zero' => [0.0]; + yield 'negative' => [-1.0]; + yield 'NaN' => [NAN]; + } + + /** No time left, no reserve: nothing finishes, nothing waits, and no NaN reaches a deadline. */ + #[DataProvider('noBudget')] + public function testWithoutABudgetNoCompanionFinishes(float $timeout): void + { + $transport = new RecordingTransport(complete: false); + $queue = new EventQueue(transport: $transport, shutdownTimeoutSeconds: $timeout); + $companion = new RecordingCompanion(); + $queue->attach($companion); + $queue->add(new QueuedEvent($this->event())); + + $queue->shutdown(); + + $this->assertSame(['tick'], $companion->calls); + $this->assertSame(0, $transport->waits); + $this->assertSame([], $this->logger->messages()); + } + + /** A hook or an exporter that calls `shutdown()` inside `pump()`: the companions finish only after the delivery. */ + #[Group('ac-03-9')] + public function testAReentrantShutdownFinishesNoCompanion(): void + { + $transport = new RecordingTransport(); + $exporter = new class implements ExporterSink { + public ?EventQueue $queue = null; + + #[\Override] + public function export(Event $event): void + { + $this->queue?->shutdown(); + } + }; + $queue = new EventQueue(transport: $transport, exporters: $exporter); + $exporter->queue = $queue; + $companion = new RecordingCompanion(); + $queue->attach($companion); + $queue->add(new QueuedEvent($this->event())); + + $queue->pump(); + + $this->assertSame(['tick'], $companion->calls, 'no finish before the event is delivered'); + $this->assertCount(1, $transport->deliveries); + + $queue->shutdown(); + + $this->assertSame(['tick', 'tick', 'finish'], $companion->calls); + } + + /** The same call inside a shutdown's first pass, after that shutdown fixed the deadline: still no early finish. */ + #[Group('ac-03-9')] + public function testAReentrantShutdownDuringAShutdownFinishesNoCompanion(): void + { + $transport = new RecordingTransport(); + $exporter = new class implements ExporterSink { + public ?EventQueue $queue = null; + + #[\Override] + public function export(Event $event): void + { + $this->queue?->shutdown(); + } + }; + $queue = new EventQueue(transport: $transport, exporters: $exporter); + $exporter->queue = $queue; + $companion = new RecordingCompanion(); + $queue->attach($companion); + $queue->add(new QueuedEvent($this->event())); + + $queue->shutdown(); + + $this->assertSame(['tick', 'finish'], $companion->calls); + $this->assertCount(1, $transport->deliveries); + } + + /** A clock that cannot be read never fixes the deadline: one failure line per shutdown, no companion finishes. */ + public function testAFailingClockFinishesNoCompanion(): void + { + $transport = new RecordingTransport(failure: new \RuntimeException('down')); + $queue = new EventQueue(transport: $transport); + $companion = new RecordingCompanion(); + $queue->attach($companion); + $queue->add(new QueuedEvent($this->event())); + + $queue->shutdown(); + $queue->shutdown(); + + $this->assertSame([], $companion->calls); + $this->assertSame(array_merge(...array_fill(0, 2, [self::GAVE_UP . 'RuntimeException: down', 'Shutting down with 1 events still in queue'])), $this->logger->messages()); + } + + /** `finish()` is the last call a companion gets in a shutdown: the pass after it drives the channel only. */ + #[Group('ac-03-9')] + public function testShutdownFinishesEachCompanionOnceAfterDeliveringEverything(): void + { + $transport = new RecordingTransport(); + $queue = new EventQueue(transport: $transport); + $first = new RecordingCompanion(); + $second = new RecordingCompanion(); + $queue->attach($first); + $queue->attach($second); + $queue->add(new QueuedEvent($this->event())); + + $queue->shutdown(); + + $this->assertSame(['tick', 'finish'], $first->calls); + $this->assertSame(['tick', 'finish'], $second->calls); + $this->assertCount(1, $transport->deliveries); + $this->assertSame([], $this->logger->messages()); + } + + public function testAThrowingFinishNeverStopsTheShutdown(): void + { + $channel = new RecordingHttpChannel(); + $queue = new EventQueue(transport: new RecordingTransport(), channel: $channel); + $failing = new RecordingCompanion(static fn(): never => throw new \RuntimeException('finish')); + $posting = new RecordingCompanion(static function () use ($channel): void { + $channel->post(new HttpRequest('http://collector.test/v1/logs', [], '{}'), static function (): void {}); + }); + $queue->attach($failing); + $queue->attach($posting); + + $queue->shutdown(); + + $this->assertSame(['tick', 'finish'], $failing->calls); + $this->assertSame(['tick', 'finish'], $posting->calls); + $this->assertSame(['http://collector.test/v1/logs'], $channel->urls()); + $this->assertSame(0, $channel->pending(), 'the last request was driven'); + $this->assertSame([], $this->logger->messages()); + } + + public function testHasWorkIsFalseWhenNothingIsLeft(): void + { + $queue = new EventQueue(transport: new RecordingTransport(), channel: new RecordingHttpChannel()); + $queue->attach(new RecordingCompanion()); + + $this->assertFalse($queue->hasWork()); + } + + public function testHasWorkCountsEventsSideRequestsAndCompanions(): void + { + $waiting = new EventQueue(transport: new RecordingTransport()); + $waiting->add(new QueuedEvent($this->event())); + $this->assertTrue($waiting->hasWork(), 'an event waits'); + + $inDelivery = new EventQueue(transport: new RecordingTransport(complete: false)); + $inDelivery->add(new QueuedEvent($this->event())); + $inDelivery->pump(); + $this->assertSame(['queueLength' => 0, 'activeRequests' => 1], $inDelivery->stats()); + $this->assertTrue($inDelivery->hasWork(), 'an event in delivery'); + + $channel = new RecordingHttpChannel(complete: false); + $side = new EventQueue(transport: new RecordingTransport(), channel: $channel); + $channel->post(new HttpRequest('http://exporter.test/a', [], '{}'), static function (): void {}); + $this->assertTrue($side->hasWork(), 'a side request is pending'); + + $buffering = new RecordingCompanion(); + $companions = new EventQueue(transport: new RecordingTransport()); + $companions->attach(new RecordingCompanion()); + $companions->attach($buffering); + $this->assertFalse($companions->hasWork()); + $buffering->work = true; + $this->assertTrue($companions->hasWork(), 'a companion has work'); + } + + public function testHasWorkIsTrueWhenACheckThrows(): void + { + $queue = new EventQueue(transport: new RecordingTransport(failure: new \RuntimeException('down'))); + + $this->assertTrue($queue->hasWork()); + $this->assertSame([], $this->logger->messages()); + } + + public function testArmRunsTheDeliveryTriggersWithoutAnEvent(): void + { + $count = 0; + $queue = new EventQueue(transport: new RecordingTransport(), onEnqueue: static function () use (&$count): void { + ++$count; + }); + + $queue->arm(); + $queue->arm(); + + $this->assertSame(2, $count); + $this->assertSame(['queueLength' => 0, 'activeRequests' => 0], $queue->stats()); + $this->assertFalse($queue->hasWork()); + $this->assertSame([], $this->logger->messages()); + } + + #[Group('ac-03-13')] + public function testArmLogsAFailingRegistrar(): void + { + $queue = new EventQueue(transport: new RecordingTransport(), onEnqueue: static fn(): never => throw new \RuntimeException('no')); + + $queue->arm(); + + $this->assertSame(['Failed to register AgentCat event delivery as a background task: RuntimeException: no'], $this->logger->messages()); + } + + public function testArmWithoutARegistrarDoesNothing(): void + { + $queue = new EventQueue(transport: new RecordingTransport()); + + $queue->arm(); + + $this->assertFalse($queue->hasWork()); + $this->assertSame([], $this->logger->messages()); + } + + public function testExporterSinkIsTheOneGivenThenTheOneSet(): void + { + $first = new RecordingExporter(); + $second = new RecordingExporter(); + $queue = new EventQueue(transport: new RecordingTransport(), exporters: $first); + + $this->assertSame($first, $queue->exporterSink()); + + $queue->setExporterSink($second); + + $this->assertSame($second, $queue->exporterSink()); + } + + public function testChannelIsTheOneGivenElseACurlChannel(): void + { + $channel = new RecordingHttpChannel(); + + $this->assertSame($channel, (new EventQueue(transport: new RecordingTransport(), channel: $channel))->channel()); + $this->assertInstanceOf(CurlHttpChannel::class, (new EventQueue(transport: new RecordingTransport()))->channel()); + } + private function event(string $resourceName = 'tool', ?string $projectId = 'proj_test'): Event { return new Event(projectId: $projectId, eventType: 'mcp:tools/call', resourceName: $resourceName); @@ -736,4 +1252,50 @@ private function resourceNames(RecordingTransport $transport): array $transport->deliveries, ); } + + /** + * An exporter sink that posts one side request per event, like an exporter does. + * + * @param (\Closure(HttpResponse): void)|null $onDone + */ + private function posting(HttpChannel $channel, ?\Closure $onDone = null): ExporterSink + { + return new readonly class ($channel, $onDone) implements ExporterSink { + /** @param (\Closure(HttpResponse): void)|null $onDone */ + public function __construct(private HttpChannel $channel, private ?\Closure $onDone) {} + + #[\Override] + public function export(Event $event): void + { + $this->channel->post(new HttpRequest('http://exporter.test/' . $event->resourceName, [], '{}'), $this->onDone ?? static function (): void {}); + } + }; + } + + public function testAQueueWithoutADrainHasNoSignalHandlersToDisable(): void + { + $this->assertTrue((new EventQueue(transport: new RecordingTransport()))->disableSignalHandlers()); + } + + #[RequiresPhpExtension('pcntl')] + public function testDisableSignalHandlersAsksTheDrain(): void + { + $term = pcntl_signal_get_handler(\SIGTERM); + $int = pcntl_signal_get_handler(\SIGINT); + $async = pcntl_async_signals(); + + try { + $fresh = new EventQueue(transport: new RecordingTransport(), signalDrain: new SignalDrain(static fn(): EventQueue => throw new \LogicException('unused'), null, static fn(string $f): bool => true, 'cli')); + $this->assertTrue($fresh->disableSignalHandlers()); + + $drain = new SignalDrain(static fn(): EventQueue => throw new \LogicException('unused'), null, static fn(string $f): bool => true, 'cli'); + $drain->install(); + $installed = new EventQueue(transport: new RecordingTransport(), signalDrain: $drain); + $this->assertFalse($installed->disableSignalHandlers()); + } finally { + pcntl_signal(\SIGTERM, $term); + pcntl_signal(\SIGINT, $int); + pcntl_async_signals($async); + } + } } diff --git a/tests/Unit/Internal/Delivery/ShutdownFlusherTest.php b/tests/Unit/Internal/Delivery/ShutdownFlusherTest.php index 38fcd8f..f0b6bd3 100644 --- a/tests/Unit/Internal/Delivery/ShutdownFlusherTest.php +++ b/tests/Unit/Internal/Delivery/ShutdownFlusherTest.php @@ -10,7 +10,10 @@ use AgentCat\Internal\Delivery\ShutdownFlusher; use AgentCat\Internal\Logging\Log; use AgentCat\Internal\Pipeline\QueuedEvent; +use AgentCat\Internal\Transport\HttpRequest; use AgentCat\Tests\Support\MemoryLogger; +use AgentCat\Tests\Support\RecordingCompanion; +use AgentCat\Tests\Support\RecordingHttpChannel; use AgentCat\Tests\Support\RecordingTransport; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; @@ -141,6 +144,36 @@ public function testFinalPassFinishesTheRequestForInFlightDeliveries(): void $this->assertSame(1, $finished); } + /** Exporter and diagnostics requests are work too: the client gets its response before they are sent. */ + public function testFinalPassFinishesTheRequestForPendingSideRequests(): void + { + $finishedForTheRequest = 0; + $channel = new RecordingHttpChannel(complete: false); + $sideRequest = new EventQueue(transport: new RecordingTransport(), shutdownTimeoutSeconds: 0.0, channel: $channel); + $channel->post(new HttpRequest('http://exporter.test/a', [], '{}'), static function (): void {}); + + (new ShutdownFlusher(static fn(): EventQueue => $sideRequest, $this->recorder(), static function () use (&$finishedForTheRequest): void { + ++$finishedForTheRequest; + }))->onFinalShutdown(); + + $this->assertSame(1, $finishedForTheRequest); + $this->assertSame(1, $channel->pending(), 'the zero budget leaves it pending'); + + $finishedForTheCompanion = 0; + $companion = new RecordingCompanion(); + $companion->work = true; + $buffered = new EventQueue(transport: new RecordingTransport(), channel: new RecordingHttpChannel()); + $buffered->attach($companion); + + (new ShutdownFlusher(static fn(): EventQueue => $buffered, $this->recorder(), static function () use (&$finishedForTheCompanion): void { + ++$finishedForTheCompanion; + }))->onFinalShutdown(); + + $this->assertSame(1, $finishedForTheCompanion); + $this->assertSame(['tick', 'finish'], $companion->calls); + $this->assertSame([], $this->logger->messages()); + } + public function testFinalPassWithAnEmptyQueueDoesNotFinishTheRequest(): void { $finished = 0; diff --git a/tests/Unit/Internal/Delivery/SignalDrainTest.php b/tests/Unit/Internal/Delivery/SignalDrainTest.php index dc71a06..03d6f73 100644 --- a/tests/Unit/Internal/Delivery/SignalDrainTest.php +++ b/tests/Unit/Internal/Delivery/SignalDrainTest.php @@ -311,4 +311,30 @@ private function queued(string $name): QueuedEvent { return new QueuedEvent(new Event(projectId: 'proj_test', eventType: 'mcp:tools/call', resourceName: $name)); } + + #[RequiresPhpExtension('pcntl')] + public function testDisableBeforeInstallKeepsTheHandlersAsTheyWere(): void + { + $before = pcntl_signal_get_handler(\SIGTERM); + $beforeInt = pcntl_signal_get_handler(\SIGINT); + $async = pcntl_async_signals(); + $drain = new SignalDrain(fn(): EventQueue => $this->queue, static function (int $signal): void {}, static fn(string $f): bool => true, 'cli'); + + $this->assertTrue($drain->disable()); + $drain->install(); + + $this->assertSame($before, pcntl_signal_get_handler(\SIGTERM)); + $this->assertSame($beforeInt, pcntl_signal_get_handler(\SIGINT)); + $this->assertSame($async, pcntl_async_signals(), 'the switch leaves the async-signal mode alone'); + } + + #[RequiresPhpExtension('pcntl')] + public function testDisableAfterInstallReportsThatItIsTooLate(): void + { + $drain = new SignalDrain(fn(): EventQueue => $this->queue, static function (int $signal): void {}, static fn(string $f): bool => true, 'cli'); + $drain->install(); + + $this->assertFalse($drain->disable()); + $this->assertNotSame(\SIG_DFL, pcntl_signal_get_handler(\SIGTERM)); + } } diff --git a/tests/Unit/Internal/Diagnostics/DiagnosticsAttributesTest.php b/tests/Unit/Internal/Diagnostics/DiagnosticsAttributesTest.php new file mode 100644 index 0000000..dbbf60f --- /dev/null +++ b/tests/Unit/Internal/Diagnostics/DiagnosticsAttributesTest.php @@ -0,0 +1,190 @@ +environment = Environment::clear('APP_ENV'); + } + + #[\Override] + protected function tearDown(): void + { + $this->environment->restore(); + } + + #[Group('ac-06-56')] + public function testTheResourceOfAProjectNamesTheProjectSdkRuntimeAndHost(): void + { + $mcp = SdkVersion::installed('mcp/sdk'); + $this->assertNotNull($mcp, 'mcp/sdk is a dev dependency'); + + $this->assertSame([ + 'agentcat.project_id' => 'proj_x', + 'agentcat.sdk.language' => 'php', + 'agentcat.sdk.version' => SdkVersion::get(), + 'agentcat.mcp_sdk.version' => $mcp, + 'process.runtime.name' => 'php', + 'process.runtime.version' => PHP_VERSION, + 'process.pid' => (string) getmypid(), + 'os.type' => DiagnosticsAttributes::osType(PHP_OS_FAMILY), + 'os.version' => php_uname('r'), + 'host.arch' => DiagnosticsAttributes::arch(php_uname('m')), + ], $this->pairs(DiagnosticsAttributes::resource('proj_x'))); + } + + /** + * On the wire, so that a null or empty value cannot hide behind the PHPDoc type. `APP_ENV` is unset and the host + * functions are switched off, so some values are null before the filter. + */ + public function testEveryValueIsANonEmptyString(): void + { + $none = static fn(): bool => false; + + foreach ([DiagnosticsAttributes::resource('proj_x'), DiagnosticsAttributes::resource('', $none), DiagnosticsAttributes::record()] as $attributes) { + $decoded = Json::decode(Json::encodeJs($attributes) ?? 'null'); + $this->assertIsList($decoded); + foreach ($decoded as $attribute) { + $value = JsonAssert::at($attribute, 'value', 'stringValue'); + $this->assertIsString($value, Json::encodeJs($attribute) ?? ''); + $this->assertNotSame('', $value, Json::encodeJs($attribute) ?? ''); + } + } + } + + /** @return iterable, list}> */ + public static function disabledFunctions(): iterable + { + yield 'php_uname' => [['php_uname'], ['os.version', 'host.arch']]; + yield 'getmypid' => [['getmypid'], ['process.pid']]; + yield 'gethostname' => [['gethostname'], []]; + yield 'all three' => [['php_uname', 'getmypid', 'gethostname'], ['process.pid', 'os.version', 'host.arch']]; + } + + /** + * A hardened host can list these in `disable_functions`: only their attributes are left out (TS: partial + * attributes are fine). Without `gethostname()` the install ID hashes an empty host name (TS `?? ""`). + * + * @param list $disabled + * @param list $missing + */ + #[DataProvider('disabledFunctions')] + public function testADisabledHostFunctionLeavesOutOnlyItsAttribute(array $disabled, array $missing): void + { + $exists = static fn(string $function): bool => !in_array($function, $disabled, true); + $all = $this->pairs(DiagnosticsAttributes::resource('proj_x')); + + $this->assertSame(array_diff_key($all, array_flip($missing)), $this->pairs(DiagnosticsAttributes::resource('proj_x', $exists))); + $host = in_array('gethostname', $disabled, true) ? '' : (string) gethostname(); + $installId = $this->pairs(DiagnosticsAttributes::resource('', $exists))['agentcat.install_id'] ?? null; + $this->assertSame(DiagnosticsAttributes::installId($host, 'file://' . dirname(__DIR__, 4)), $installId); + } + + public function testAppEnvBecomesTheDeploymentEnvironmentAtTheEnd(): void + { + putenv('APP_ENV=staging'); + + $pairs = $this->pairs(DiagnosticsAttributes::resource('proj_x')); + + $this->assertSame('deployment.environment', array_key_last($pairs)); + $this->assertSame('staging', $pairs['deployment.environment'] ?? null); + } + + #[Group('ac-06-56')] + public function testWithoutAProjectTheInstallIdComesFirst(): void + { + $pairs = $this->pairs(DiagnosticsAttributes::resource('')); + + $this->assertSame('agentcat.install_id', array_key_first($pairs)); + $this->assertArrayNotHasKey('agentcat.project_id', $pairs); + $id = $pairs['agentcat.install_id'] ?? ''; + $this->assertSame(DiagnosticsAttributes::installId((string) gethostname(), 'file://' . dirname(__DIR__, 4)), $id); + $this->assertMatchesRegularExpression('/\A[0-9a-f]{16}\z/', $id); + } + + public function testEveryRecordCarriesTheVersions(): void + { + $this->assertSame([ + 'agentcat.sdk.version' => SdkVersion::get(), + 'process.runtime.version' => PHP_VERSION, + 'agentcat.mcp_sdk.version' => (string) SdkVersion::installed('mcp/sdk'), + ], $this->pairs(DiagnosticsAttributes::record())); + } + + public function testTheInstallIdHashesTheHostAndThePath(): void + { + $this->assertSame(substr(hash('sha256', 'host|file:///app'), 0, 16), DiagnosticsAttributes::installId('host', 'file:///app')); + } + + /** @return iterable */ + public static function families(): iterable + { + yield 'Linux' => ['Linux', 'linux']; + yield 'Darwin' => ['Darwin', 'darwin']; + yield 'Windows' => ['Windows', 'win32']; + yield 'BSD' => ['BSD', 'bsd']; + yield 'Solaris' => ['Solaris', 'solaris']; + yield 'Unknown' => ['Unknown', 'unknown']; + } + + /** Ruling P12: only linux, darwin and win32 are mapped. */ + #[DataProvider('families')] + public function testTheOsTypeUsesTheNodeNamesForTheThreeMainSystems(string $family, string $expected): void + { + $this->assertSame($expected, DiagnosticsAttributes::osType($family)); + } + + /** @return iterable */ + public static function machines(): iterable + { + yield 'x86_64' => ['x86_64', 'x64']; + yield 'amd64' => ['amd64', 'x64']; + yield 'AMD64 (Windows)' => ['AMD64', 'x64']; + yield 'aarch64' => ['aarch64', 'arm64']; + yield 'arm64' => ['arm64', 'arm64']; + yield 'ARM64 (Windows)' => ['ARM64', 'arm64']; + yield 'i686' => ['i686', 'i686']; + yield 'riscv64' => ['riscv64', 'riscv64']; + yield 'mixed case kept' => ['Power Macintosh', 'Power Macintosh']; + } + + /** Ruling P12: only x64 and arm64 are mapped; any other machine name is sent as is. */ + #[DataProvider('machines')] + public function testTheArchitectureUsesTheNodeNamesForX64AndArm64(string $machine, string $expected): void + { + $this->assertSame($expected, DiagnosticsAttributes::arch($machine)); + } + + /** + * @param list $attributes + * + * @return array + */ + private function pairs(array $attributes): array + { + $pairs = []; + foreach ($attributes as $attribute) { + $this->assertArrayNotHasKey($attribute['key'], $pairs, 'each key once'); + $pairs[$attribute['key']] = $attribute['value']['stringValue']; + } + + return $pairs; + } +} diff --git a/tests/Unit/Internal/Diagnostics/DiagnosticsEndpointTest.php b/tests/Unit/Internal/Diagnostics/DiagnosticsEndpointTest.php new file mode 100644 index 0000000..c8c7648 --- /dev/null +++ b/tests/Unit/Internal/Diagnostics/DiagnosticsEndpointTest.php @@ -0,0 +1,93 @@ +environment = Environment::clear('AGENTCAT_DIAGNOSTICS_ENDPOINT', 'AGENTCAT_DIAGNOSTICS_TOKEN', 'DIAGNOSTICS_ENDPOINT', 'DIAGNOSTICS_TOKEN'); + putenv('AGENTCAT_DIAGNOSTICS_ENDPOINT=' . MockCollector::closedPortUrl()); // fail closed (ruling P8) + } + + #[\Override] + protected function tearDown(): void + { + $this->environment->restore(); + } + + #[Group('ac-06-54')] + public function testWithoutVariablesTheDefaultCollectorAndTokenAreUsed(): void + { + putenv('AGENTCAT_DIAGNOSTICS_ENDPOINT'); // this test posts nothing + + $this->assertSame('https://otel.agentcat.com/v1/logs', DiagnosticsEndpoint::url()); + $this->assertSame('dgk_sdk_diag_3f9a2c7e1b8d4065af2e9c1d7b6a4f80', DiagnosticsEndpoint::token()); + } + + /** @return iterable */ + public static function bases(): iterable + { + yield 'trailing slash' => ['https://c.example/', 'https://c.example/v1/logs']; + yield 'no path' => ['https://c.example', 'https://c.example/v1/logs']; + yield 'with a path' => ['https://c.example/base', 'https://c.example/base/v1/logs']; + yield 'full URL' => ['https://c.example/v1/logs', 'https://c.example/v1/logs']; + yield 'full URL with slashes' => ['https://c.example/v1/logs//', 'https://c.example/v1/logs']; + } + + #[DataProvider('bases')] + #[Group('ac-06-55')] + public function testTheVariableSetsTheCollector(string $base, string $expected): void + { + putenv('AGENTCAT_DIAGNOSTICS_ENDPOINT=' . $base); + + $this->assertSame($expected, DiagnosticsEndpoint::url()); + } + + public function testTheVariableIsReadFromTheServerArrayToo(): void + { + putenv('AGENTCAT_DIAGNOSTICS_ENDPOINT'); + $_SERVER['AGENTCAT_DIAGNOSTICS_ENDPOINT'] = 'https://fpm.example'; + $_SERVER['AGENTCAT_DIAGNOSTICS_TOKEN'] = 'fpm'; + + $this->assertSame('https://fpm.example/v1/logs', DiagnosticsEndpoint::url()); + $this->assertSame('fpm', DiagnosticsEndpoint::token()); + } + + /** A10 (D50): the unprefixed names belong to the application; AgentCat never reads them. */ + #[Group('ac-06-55')] + public function testTheUnprefixedVariablesOfTheApplicationAreIgnored(): void + { + putenv('AGENTCAT_DIAGNOSTICS_ENDPOINT'); + putenv('DIAGNOSTICS_ENDPOINT=https://diagnostics.app.example'); + putenv('DIAGNOSTICS_TOKEN=the-applications-own-secret'); + $_SERVER['DIAGNOSTICS_ENDPOINT'] = $_ENV['DIAGNOSTICS_ENDPOINT'] = 'https://diagnostics.app.example'; + $_SERVER['DIAGNOSTICS_TOKEN'] = $_ENV['DIAGNOSTICS_TOKEN'] = 'the-applications-own-secret'; + + $this->assertSame('https://otel.agentcat.com/v1/logs', DiagnosticsEndpoint::url()); + $this->assertSame(Constants::DEFAULT_DIAGNOSTICS_TOKEN, DiagnosticsEndpoint::token()); + } + + #[Group('ac-06-55')] + public function testTheTokenVariableReplacesTheDefaultUnlessItIsEmpty(): void + { + putenv('AGENTCAT_DIAGNOSTICS_TOKEN=t'); + $this->assertSame('t', DiagnosticsEndpoint::token()); + + putenv('AGENTCAT_DIAGNOSTICS_TOKEN='); + $this->assertSame(Constants::DEFAULT_DIAGNOSTICS_TOKEN, DiagnosticsEndpoint::token()); + } +} diff --git a/tests/Unit/Internal/Diagnostics/DiagnosticsSwitchTest.php b/tests/Unit/Internal/Diagnostics/DiagnosticsSwitchTest.php new file mode 100644 index 0000000..ecc6ce4 --- /dev/null +++ b/tests/Unit/Internal/Diagnostics/DiagnosticsSwitchTest.php @@ -0,0 +1,103 @@ +assertTrue(DiagnosticsSwitch::enabled(false, null, null, false)); + } + + /** @return iterable */ + public static function disablingValues(): iterable + { + foreach (['true', 'TRUE', '1', 'yes', 'on', 'anything', ' true '] as $value) { + yield $value => [$value]; + } + } + + #[DataProvider('disablingValues')] + #[Group('ac-06-49')] + #[Group('ac-01-62')] + public function testATrueOrUnknownValueTurnsDiagnosticsOff(string $value): void + { + $this->assertSame(DiagnosticsSwitch::DISABLED, DiagnosticsSwitch::flag($value)); + $this->assertFalse(DiagnosticsSwitch::enabled(false, $value, null, false)); + $this->assertFalse(DiagnosticsSwitch::enabled(false, $value, null, true)); + } + + /** @return iterable */ + public static function enablingValues(): iterable + { + foreach (['false', 'FALSE', '0', 'no', 'off', ' Off ', "\u{a0}no\t"] as $value) { + yield $value => [$value]; + } + } + + #[DataProvider('enablingValues')] + #[Group('ac-06-50')] + #[Group('ac-01-63')] + public function testAFalseValueTurnsDiagnosticsOnEvenUnderATestRunner(string $value): void + { + $this->assertSame(DiagnosticsSwitch::FORCE_ENABLED, DiagnosticsSwitch::flag($value)); + $this->assertTrue(DiagnosticsSwitch::enabled(false, $value, null, true)); + $this->assertTrue(DiagnosticsSwitch::enabled(false, $value, null, false)); + } + + /** @return iterable */ + public static function unsetValues(): iterable + { + yield 'null' => [null]; + yield 'empty' => ['']; + yield 'blanks' => [' ']; + } + + #[DataProvider('unsetValues')] + #[Group('ac-06-51')] + #[Group('ac-01-64')] + public function testABlankValueLetsTheTestEnvironmentDecide(?string $value): void + { + $this->assertSame(DiagnosticsSwitch::UNSET, DiagnosticsSwitch::flag($value)); + $this->assertTrue(DiagnosticsSwitch::enabled(false, $value, null, false)); + $this->assertFalse(DiagnosticsSwitch::enabled(false, $value, null, true)); + } + + /** A10 (D50): `DISABLE_DIAGNOSTICS`, the unprefixed name, can only turn diagnostics off. */ + #[Group('ac-06-49')] + public function testTheUnprefixedFallbackCanOnlyTurnDiagnosticsOff(): void + { + $this->assertFalse(DiagnosticsSwitch::enabled(false, null, 'true', false), 'true turns them off'); + $this->assertFalse(DiagnosticsSwitch::enabled(false, ' ', 'anything', false), 'any other value too'); + $this->assertFalse(DiagnosticsSwitch::enabled(false, null, 'false', true), 'false never turns them on'); + $this->assertTrue(DiagnosticsSwitch::enabled(false, null, 'false', false), 'and does not turn them off'); + $this->assertTrue(DiagnosticsSwitch::enabled(false, null, '', false)); + } + + /** D50 ruling: when the two names disagree, any off value wins; a false value never turns them on over a true one. */ + #[Group('ac-06-49')] + public function testWhenTheTwoVariablesDisagreeOffWins(): void + { + $this->assertFalse(DiagnosticsSwitch::enabled(false, 'false', 'true', true), 'DISABLE_DIAGNOSTICS=true over AGENTCAT_DISABLE_DIAGNOSTICS=false'); + $this->assertFalse(DiagnosticsSwitch::enabled(false, 'false', 'true', false)); + $this->assertFalse(DiagnosticsSwitch::enabled(false, 'false', 'anything', false), 'any off word'); + $this->assertFalse(DiagnosticsSwitch::enabled(false, 'true', 'false', false), 'AGENTCAT_DISABLE_DIAGNOSTICS=true over DISABLE_DIAGNOSTICS=false'); + $this->assertTrue(DiagnosticsSwitch::enabled(false, 'false', 'false', true), 'two false values agree: on, also under a test runner'); + } + + #[Group('ac-06-48')] + public function testTheOptionWinsOverTheVariable(): void + { + $this->assertFalse(DiagnosticsSwitch::enabled(true, 'false', null, false)); + $this->assertFalse(DiagnosticsSwitch::enabled(true, null, null, false)); + } +} diff --git a/tests/Unit/Internal/Diagnostics/DiagnosticsTest.php b/tests/Unit/Internal/Diagnostics/DiagnosticsTest.php new file mode 100644 index 0000000..57bca24 --- /dev/null +++ b/tests/Unit/Internal/Diagnostics/DiagnosticsTest.php @@ -0,0 +1,775 @@ +environment = Environment::clear(...self::VARIABLES); + putenv('AGENTCAT_DIAGNOSTICS_ENDPOINT=' . MockCollector::closedPortUrl()); // fail closed (ruling P8) + putenv('DIAGNOSTICS_ENDPOINT=' . MockCollector::closedPortUrl()); // an application's own variable: never read (D50) + Diagnostics::reset(); + $this->logger = new MemoryLogger(); + Log::setLogger($this->logger); + $this->http = new RecordingHttpChannel(); + $this->queue = $this->queueOn($this->http); + } + + #[\Override] + protected function tearDown(): void + { + Diagnostics::reset(); + Log::reset(); + $this->environment->restore(); + } + + #[Group('ac-06-54')] + public function testAWarningLineIsPostedToTheDefaultCollector(): void + { + putenv('AGENTCAT_DIAGNOSTICS_ENDPOINT'); // the default URL, with a recording channel (ruling F8) + $this->init('proj_ABC'); + Log::write('Warning: something'); + + $this->queue->shutdown(); + + $this->assertCount(1, $this->http->requests); + $request = $this->http->requests[0]; + $this->assertSame('https://otel.agentcat.com/v1/logs', $request->url); + $this->assertSame(['Content-Type' => 'application/json', 'Authorization' => 'Bearer dgk_sdk_diag_3f9a2c7e1b8d4065af2e9c1d7b6a4f80'], $request->headers); + $body = Json::decode($request->body); + $this->assertSame(Constants::DIAGNOSTICS_SCOPE_NAME, JsonAssert::at($body, 'resourceLogs', 0, 'scopeLogs', 0, 'scope', 'name')); + $this->assertSame(SdkVersion::get(), JsonAssert::at($body, 'resourceLogs', 0, 'scopeLogs', 0, 'scope', 'version')); + $this->assertSame(Json::encodeJs(DiagnosticsAttributes::resource('proj_ABC')), Json::encodeJs(JsonAssert::at($body, 'resourceLogs', 0, 'resource', 'attributes'))); + $record = JsonAssert::at($body, 'resourceLogs', 0, 'scopeLogs', 0, 'logRecords', 0); + $this->assertSame(13, JsonAssert::at($record, 'severityNumber')); + $this->assertSame('WARN', JsonAssert::at($record, 'severityText')); + $this->assertMatchesRegularExpression('/\A\[\d{4}-\d\d\-\d\dT\d\d:\d\d:\d\d\.\d{3}Z\] \[sdk=\S+ php=\S+ mcp=\S+\] Warning: something\z/', $this->text($record)); + $this->assertSame('1792163096821000000', JsonAssert::at($record, 'timeUnixNano')); + $this->assertSame(Json::encodeJs(DiagnosticsAttributes::record()), Json::encodeJs(JsonAssert::at($record, 'attributes'))); + $this->assertSame(1, $this->armed, 'the first line registers the shutdown delivery'); + } + + public function testOnlyTheFirstLineArmsTheQueue(): void + { + $this->init('proj_ABC'); + + Log::write('one'); + Log::write('two'); + Log::write('three'); + + $this->assertSame(1, $this->armed); + } + + /** The arm() failure line comes back through capture(): it is buffered, and arm() is not called again. */ + public function testAFailingDeliveryRegistrationIsBufferedOnce(): void + { + $calls = 0; + $this->queue = new EventQueue(transport: new RecordingTransport(), onEnqueue: static function () use (&$calls): never { + ++$calls; + + throw new \RuntimeException('no shutdown functions'); + }, channel: $this->http); + $this->init('proj_ABC'); + + Log::write('one'); + $this->queue->shutdown(); + + $failure = 'Failed to register AgentCat event delivery as a background task: RuntimeException: no shutdown functions'; + $this->assertSame(1, $calls); + $this->assertSame([$failure, 'one'], $this->logger->messages(), 'the sink runs first, so the nested line reaches the logger first'); + $records = $this->records(0); + $this->assertCount(2, $records); + $this->assertStringEndsWith('] one', $this->text($records[0])); + $this->assertStringEndsWith('] ' . $failure, $this->text($records[1])); + } + + #[Group('ac-06-58')] + public function testTheBufferKeepsTheNewest1000Records(): void + { + $this->init('proj_ABC'); + for ($i = 0; $i <= Diagnostics::MAX_BUFFER; ++$i) { + Log::write('line ' . $i); + } + + $this->queue->shutdown(); + + $records = $this->records(0); + $this->assertCount(1000, $records); + $this->assertStringEndsWith('] line 1', $this->text($records[0])); + $this->assertStringEndsWith('] line 1000', $this->text($records[999] ?? null)); + } + + /** A6: the buffer holds at most 1 MiB of record bodies; the oldest records go first, silently. */ + #[Group('ac-06-58')] + public function testTheBufferKeepsItsRecordBodiesWithinOneMebibyte(): void + { + $this->init('proj_ABC'); + $text = str_repeat('x', 100_000); + for ($i = 0; $i < 12; ++$i) { + Log::write($i . ' ' . $text); + } + + $this->queue->shutdown(); + + $records = $this->records(0); + $this->assertCount(10, $records, 'ten bodies of about 100 KB fit in 1 MiB, eleven do not'); + $this->assertStringEndsWith('] 2 ' . $text, $this->text($records[0])); + $this->assertStringEndsWith('] 11 ' . $text, $this->text($records[9])); + $bytes = array_sum(array_map(fn(mixed $record): int => strlen($this->text($record)), $records)); + $this->assertLessThanOrEqual(Diagnostics::MAX_BUFFER_BYTES, $bytes); + $this->assertCount(12, $this->logger->messages(), 'no line about the dropped records'); + } + + /** A6: records that fill the budget exactly all stay, one byte more drops the oldest; each batch starts a new budget. */ + public function testTheByteBudgetHoldsExactlyOneMebibyte(): void + { + $this->init('proj_ABC'); + $half = intdiv(Diagnostics::MAX_BUFFER_BYTES, 2) - strlen(LogLine::format('')); + Log::write(str_repeat('a', $half)); + Log::write(str_repeat('b', $half)); + $this->now += 2.0; + $this->queue->pump(); + Log::write(str_repeat('c', $half)); + Log::write(str_repeat('d', $half + 1)); + + $this->queue->shutdown(); + + $this->assertCount(2, $this->records(0), 'exactly 1 MiB of bodies fits'); + $second = $this->records(1); + $this->assertCount(1, $second, 'one byte more does not'); + $this->assertStringContainsString('] dd', $this->text($second[0])); + } + + /** A6: a line larger than the whole budget is kept alone, until the next line drops it; each batch starts a new budget. */ + public function testALineOverTheBudgetIsKeptAlone(): void + { + $this->init('proj_ABC'); + Log::write('small'); + Log::write('huge ' . str_repeat('x', Diagnostics::MAX_BUFFER_BYTES)); + $this->now += 2.0; + $this->queue->pump(); + Log::write('first after the batch'); + Log::write('huge again ' . str_repeat('x', Diagnostics::MAX_BUFFER_BYTES)); + Log::write('last'); + + $this->queue->shutdown(); + + $this->assertSame(2, $this->posts()); + $first = $this->records(0); + $this->assertCount(1, $first); + $this->assertStringContainsString('] huge xxx', $this->text($first[0])); + $second = $this->records(1); + $this->assertCount(1, $second, 'each line drops the older records that do not fit beside it'); + $this->assertStringEndsWith('] last', $this->text($second[0])); + } + + /** Q3: no timer — a queue pass after the 2 s window posts; a pass before it does not. */ + public function testTheTwoSecondWindowIsCheckedOnQueuePasses(): void + { + $this->init('proj_ABC'); + Log::write('first'); + $this->now += 1.5; + Log::write('more'); + $this->queue->pump(); + $this->assertSame(0, $this->posts()); + + $this->now += 0.5; // exactly 2.0 s after the first line (binary-exact steps) + $this->queue->pump(); + $this->assertSame(1, $this->posts()); + $this->assertCount(2, $this->records(0), 'the window starts with the first buffered line'); + + Log::write('second'); + $this->queue->pump(); + $this->assertSame(1, $this->posts(), 'a new window starts with the next line'); + + $this->now += 2.0; + $this->assertTrue($this->queue->flush(1.0), 'flush() passes check the window too'); + $this->assertSame(2, $this->posts()); + } + + /** + * I1: a collector that never answers (black-holed otel.agentcat.com). The flush pass posts the batch, as its window + * is over, and returns true at once: AgentCat's own request is neither counted nor waited for. + */ + public function testFlushNeitherCountsNorWaitsForTheBatchItPosts(): void + { + $this->http = new RecordingHttpChannel(complete: false); + $this->queue = $this->queueOn($this->http); + $this->init('proj_ABC'); + Log::write('a line'); + $this->now += 2.0; + + $this->assertTrue($this->queue->flush(0.3)); + + $this->assertSame(1, $this->posts(), 'the flush pass posted the batch'); + $this->assertTrue(($this->http->requests[0] ?? null)?->background); + $this->assertSame([], $this->http->waits, 'and did not wait for it'); + $this->assertSame(1, $this->http->pending()); + } + + /** D47: `shutdown()` still waits for the batch inside its budget. */ + public function testShutdownWaitsForTheBatchItPosts(): void + { + $this->http = new RecordingHttpChannel(complete: false, onWait: function (): void { + $this->http->complete = true; // the collector answers during the first wait + }); + $this->queue = $this->queueOn($this->http); + $this->init('proj_ABC'); + Log::write('a line'); + + $this->queue->shutdown(); + + $this->assertSame(1, $this->posts()); + $this->assertCount(1, $this->http->waits); + $this->assertSame(0, $this->http->pending(), 'the batch was sent'); + } + + #[Group('ac-06-59')] + public function testAnUnreachableCollectorDiscardsTheRecordsSilently(): void + { + $this->http = new RecordingHttpChannel(default: HttpResponse::failed('cURL error 7: refused')); + $this->queue = $this->queueOn($this->http); + $this->init('proj_ABC'); + Log::write('one'); + + $this->queue->shutdown(); + + $this->assertCount(1, $this->http->requests); + $this->assertFalse($this->queue->hasWork()); + $this->assertSame(['one'], $this->logger->messages(), 'no line about the failure'); + } + + public function testAChannelThatThrowsLosesTheRecordsSilently(): void + { + $channel = new class implements HttpChannel { + public int $posts = 0; + + #[\Override] + public function post(HttpRequest $request, \Closure $onDone): void + { + ++$this->posts; + + throw new \RuntimeException('post'); + } + + #[\Override] + public function pump(): void {} + + #[\Override] + public function pending(bool $includeBackground = true): int + { + return 0; + } + + #[\Override] + public function waitForActivity(float $seconds): void {} + }; + $this->queue = $this->queueOn($channel); + $this->init('proj_ABC'); + Log::write('one'); + + $this->queue->shutdown(); + + $this->assertSame(1, $channel->posts); + $this->assertFalse($this->queue->hasWork()); + $this->assertSame(['one'], $this->logger->messages()); + } + + /** Review Focus 5 (ruling P6): a real file at the `/v1/logs` path, so a fetched file would come back as the body. */ + public function testAFileEndpointIsNeverRead(): void + { + $dir = getenv('HOME') . '/diagnostics-' . bin2hex(random_bytes(6)); + mkdir($dir . '/v1', 0o700, true); + file_put_contents($dir . '/v1/logs', 'root:x:0:0:root:/root:/bin/sh'); + $channel = new class (new CurlHttpChannel()) implements HttpChannel { + /** @var list */ + public array $requests = []; + + /** @var list */ + public array $responses = []; + + public function __construct(private readonly HttpChannel $inner) {} + + #[\Override] + public function post(HttpRequest $request, \Closure $onDone): void + { + $this->requests[] = $request; + $this->inner->post($request, function (HttpResponse $response) use ($onDone): void { + $this->responses[] = $response; + $onDone($response); + }); + } + + #[\Override] + public function pump(): void + { + $this->inner->pump(); + } + + #[\Override] + public function pending(bool $includeBackground = true): int + { + return $this->inner->pending($includeBackground); + } + + #[\Override] + public function waitForActivity(float $seconds): void + { + $this->inner->waitForActivity($seconds); + } + }; + + try { + putenv('AGENTCAT_DIAGNOSTICS_ENDPOINT=file://' . $dir . '/v1/logs'); + $this->queue = $this->queueOn($channel); + $this->init('proj_ABC'); + Log::write('one'); + + $this->queue->shutdown(); + + $this->assertSame(['file://' . $dir . '/v1/logs'], array_map(static fn(HttpRequest $request): string => $request->url, $channel->requests)); + $this->assertCount(1, $channel->responses); + $this->assertMatchesRegularExpression('/\AcURL error 1: /', (string) $channel->responses[0]->error); + $this->assertSame('', $channel->responses[0]->body, 'the file was not read'); + $this->assertSame(['one'], $this->logger->messages(), 'diagnostics stay silent'); + $this->assertFalse($this->queue->hasWork()); + } finally { + unlink($dir . '/v1/logs'); + rmdir($dir . '/v1'); + rmdir($dir); + } + } + + /** @return iterable */ + public static function anyVariable(): iterable + { + yield 'unset' => [null]; + yield 'AGENTCAT_DISABLE_DIAGNOSTICS=false' => ['false']; + } + + #[DataProvider('anyVariable')] + #[Group('ac-06-48')] + #[Group('ac-01-61')] + public function testTheDisableOptionBuffersAndSendsNothing(?string $variable): void + { + if ($variable !== null) { + putenv('AGENTCAT_DISABLE_DIAGNOSTICS=' . $variable); + } + $this->init('p', disabled: true); + Log::write('x'); + + $this->assertFalse(Diagnostics::enabled()); + $this->assertFalse($this->queue->hasWork()); + $this->assertSame(0, $this->armed); + $this->queue->shutdown(); + $this->assertSame([], $this->http->requests); + } + + #[Group('ac-06-49')] + #[Group('ac-01-62')] + public function testATrueVariableTurnsDiagnosticsOff(): void + { + putenv('AGENTCAT_DISABLE_DIAGNOSTICS=true'); + $this->init('p'); + Log::write('x'); + + $this->assertFalse(Diagnostics::enabled()); + $this->assertFalse($this->queue->hasWork()); + } + + /** A10 (D50): the unprefixed `DISABLE_DIAGNOSTICS` can only turn diagnostics off, and its off wins. */ + #[Group('ac-06-49')] + public function testTheUnprefixedVariableCanOnlyTurnDiagnosticsOff(): void + { + putenv('DISABLE_DIAGNOSTICS=true'); + $this->init('p'); + $this->assertFalse(Diagnostics::enabled(), 'true turns them off'); + + Diagnostics::reset(); + putenv('DISABLE_DIAGNOSTICS=false'); + $this->init('p', testEnvironment: true); + $this->assertFalse(Diagnostics::enabled(), 'false never turns them on'); + + Diagnostics::reset(); + putenv('DISABLE_DIAGNOSTICS=true'); + putenv('AGENTCAT_DISABLE_DIAGNOSTICS=false'); + $this->init('p', testEnvironment: true); + $this->assertFalse(Diagnostics::enabled(), 'off wins over AGENTCAT_DISABLE_DIAGNOSTICS=false'); + } + + /** A10 (D50): an application's own `DIAGNOSTICS_ENDPOINT` and `DIAGNOSTICS_TOKEN` never reach AgentCat. */ + #[Group('ac-06-55')] + public function testTheApplicationsOwnCollectorVariablesAreIgnored(): void + { + putenv('AGENTCAT_DIAGNOSTICS_ENDPOINT'); // the default URL, with a recording channel (ruling F8) + putenv('DIAGNOSTICS_ENDPOINT=https://diagnostics.app.example'); + putenv('DIAGNOSTICS_TOKEN=the-applications-own-secret'); + $this->init('proj_ABC'); + Log::write('one'); + + $this->queue->shutdown(); + + $this->assertSame(['https://otel.agentcat.com/v1/logs'], $this->http->urls()); + $this->assertSame('Bearer ' . Constants::DEFAULT_DIAGNOSTICS_TOKEN, $this->http->requests[0]->headers['Authorization'] ?? null); + } + + /** A9 (D49): a blocked collector holds the shutdown 0.25 s (connect) or 1 s (answer) at most; exports keep 2 s and 10 s. */ + public function testTheBatchCarriesTheShortDiagnosticsTimeouts(): void + { + $this->init('proj_ABC'); + Log::write('one'); + + $this->queue->shutdown(); + + $request = $this->http->requests[0] ?? null; + $this->assertInstanceOf(HttpRequest::class, $request); + $this->assertSame([true, 0.25, 1.0], [$request->background, $request->connectTimeoutSeconds, $request->totalTimeoutSeconds]); + } + + #[Group('ac-06-53')] + #[Group('ac-01-11')] + public function testTheFirstInitDecides(): void + { + $this->init('p', disabled: true); + $this->init('p'); + $this->assertFalse(Diagnostics::enabled(), 'a later enabling call changes nothing'); + $this->assertSame([], $this->logger->messages()); + + Diagnostics::reset(); + $this->init('p'); + $this->init('p'); + $this->assertSame([], $this->logger->messages(), 'the same option again logs nothing'); + $this->init('p', disabled: true); + $this->init('p', disabled: true); + $this->init('p'); + $this->assertTrue(Diagnostics::enabled(), 'a later disabling call changes nothing'); + $this->assertSame([self::IGNORED], $this->logger->messages(), 'one line, once'); + $this->assertTrue($this->queue->hasWork(), 'the line is a diagnostics line too'); + } + + /** The line says diagnostics "were set up": not when the variable or the test environment kept them off. */ + #[Group('ac-06-53')] + public function testALaterDisablingCallIsSilentWhenDiagnosticsNeverStarted(): void + { + putenv('AGENTCAT_DISABLE_DIAGNOSTICS=true'); + $this->init('p'); + $this->init('p', disabled: true); + + Diagnostics::reset(); + putenv('AGENTCAT_DISABLE_DIAGNOSTICS'); + $this->init('p', testEnvironment: true); + $this->init('p', disabled: true); + + $this->assertFalse(Diagnostics::enabled()); + $this->assertSame([], $this->logger->messages()); + } + + #[Group('ac-06-52')] + #[Group('ac-01-65')] + public function testATestEnvironmentTurnsDiagnosticsOffUnlessTheVariableIsFalse(): void + { + $this->init('p', testEnvironment: true); + $this->assertFalse(Diagnostics::enabled()); + + Diagnostics::reset(); + putenv('AGENTCAT_DISABLE_DIAGNOSTICS=false'); + $this->init('p', testEnvironment: true); + $this->assertTrue(Diagnostics::enabled()); + } + + /** P5: the default test signal is `TestMode::inTestEnvironment()`, and PHPUnit runs this test. */ + #[Group('ac-06-52')] + public function testByDefaultThePhpUnitRunTurnsDiagnosticsOff(): void + { + Diagnostics::init('p', false, $this->queue); + + $this->assertFalse(Diagnostics::enabled()); + } + + #[Group('ac-06-50')] + public function testTheDefaultClocksStampTheRecordAndKeepTheWindowOpen(): void + { + putenv('AGENTCAT_DISABLE_DIAGNOSTICS=false'); + Diagnostics::init('p', false, $this->queue); + $before = (int) floor(microtime(true) * 1000); + Log::write('x'); + $after = (int) floor(microtime(true) * 1000); + + $this->queue->pump(); + $this->assertSame([], $this->http->requests, 'the 2 s window is still open'); + $this->queue->shutdown(); + + $this->assertTrue(Diagnostics::enabled()); + $stamp = JsonAssert::at($this->records(0)[0] ?? null, 'timeUnixNano'); + $this->assertIsString($stamp); + $this->assertMatchesRegularExpression('/\A\d+000000\z/', $stamp); + $millis = (int) substr($stamp, 0, -6); + $this->assertGreaterThanOrEqual($before, $millis); + $this->assertLessThanOrEqual($after, $millis); + } + + #[Group('ac-06-55')] + public function testTheCollectorAndTokenAreReadAtEachFlush(): void + { + $this->init('proj_ABC'); + putenv('AGENTCAT_DIAGNOSTICS_ENDPOINT=https://c.example/'); + putenv('AGENTCAT_DIAGNOSTICS_TOKEN=t'); + Log::write('one'); + $this->now += 2.0; + $this->queue->pump(); + + putenv('AGENTCAT_DIAGNOSTICS_ENDPOINT=https://c.example/v1/logs//'); + putenv('AGENTCAT_DIAGNOSTICS_TOKEN='); + Log::write('two'); + $this->now += 2.0; + $this->queue->pump(); + + $this->assertSame(['https://c.example/v1/logs', 'https://c.example/v1/logs'], $this->http->urls()); + $this->assertSame('Bearer t', $this->http->requests[0]->headers['Authorization'] ?? null); + $this->assertSame('Bearer ' . Constants::DEFAULT_DIAGNOSTICS_TOKEN, $this->http->requests[1]->headers['Authorization'] ?? null); + } + + #[Group('ac-06-56')] + public function testTheResourceNamesTheProjectOrAStableInstallId(): void + { + $this->init('proj_ABC'); + Log::write('one'); + $this->queue->shutdown(); + $keys = array_map(static fn(mixed $attribute): mixed => JsonAssert::at($attribute, 'key'), $this->resource(0)); + $this->assertSame('agentcat.project_id', $keys[0] ?? null); + $this->assertSame('proj_ABC', JsonAssert::at($this->resource(0)[0] ?? null, 'value', 'stringValue')); + $this->assertNotContains('agentcat.install_id', $keys); + + $first = $this->installIdOfANewProcess(); + $this->assertMatchesRegularExpression('/\A[0-9a-f]{16}\z/', $first); + $this->assertSame($first, $this->installIdOfANewProcess()); + } + + /** Spec conflict 9: while `AgentCat::fake()` diverts the log, the diagnostics buffer gets nothing. */ + public function testADivertedLineIsNotBuffered(): void + { + $this->init('proj_ABC'); + + Log::divert(new MemoryLogger()); + Log::write('x'); + $this->assertFalse($this->queue->hasWork()); + + Log::endDiversion(); + Log::write('y'); + $this->assertTrue($this->queue->hasWork()); + } + + public function testAThrowingClockLeavesTheBufferUnchanged(): void + { + $calls = 0; + Diagnostics::init('proj_ABC', false, $this->queue, static fn(): bool => false, static function () use (&$calls): float { + if (++$calls > 1) { + throw new \RuntimeException('clock'); + } + + return 100.0; + }, static fn(): int => self::WALL_MILLIS); + + Log::write('kept'); + Log::write('lost'); + $this->queue->shutdown(); // its ticks read the clock too + + $this->assertSame(['kept', 'lost'], $this->logger->messages()); + $records = $this->records(0); + $this->assertCount(1, $records); + $this->assertStringEndsWith('] kept', $this->text($records[0])); + } + + /** TS `(BigInt(Date.now()) * BigInt(1_000_000)).toString()`. */ + public function testAWallClockAtZeroStampsZero(): void + { + Diagnostics::init('proj_ABC', false, $this->queue, static fn(): bool => false, fn(): float => $this->now, static fn(): int => 0); + Log::write('epoch'); + + $this->queue->shutdown(); + + $this->assertSame('0', JsonAssert::at($this->records(0)[0] ?? null, 'timeUnixNano')); + } + + public function testAThrowingWallClockLeavesTheBufferUnchanged(): void + { + Diagnostics::init('proj_ABC', false, $this->queue, static fn(): bool => false, fn(): float => $this->now, static fn(): int => throw new \RuntimeException('wall clock')); + + Log::write('lost'); + + $this->assertFalse($this->queue->hasWork()); + $this->assertSame(0, $this->armed); + } + + public function testAThrowingTestSignalLeavesDiagnosticsOff(): void + { + Diagnostics::init('p', false, $this->queue, static fn(): bool => throw new \RuntimeException('signal')); + Log::write('x'); + + $this->assertFalse(Diagnostics::enabled()); + $this->assertFalse($this->queue->hasWork()); + } + + public function testTheQueueHasWorkOnlyWhileLinesAreBuffered(): void + { + $this->init('proj_ABC'); + $this->assertFalse($this->queue->hasWork()); + + Log::write('one'); + $this->assertTrue($this->queue->hasWork(), 'so the PHP-FPM response is finished before the post (D47)'); + + $this->now += 2.0; + $this->queue->pump(); + $this->assertCount(1, $this->http->requests); + $this->assertFalse($this->queue->hasWork()); + } + + /** `finish()` runs once per `shutdown()` (signal drain, then the shutdown function): an empty buffer posts nothing. */ + public function testEachShutdownPostsOnlyTheLinesSinceTheLast(): void + { + $this->init('proj_ABC'); + Log::write('one'); + $this->queue->shutdown(); + $this->queue->shutdown(); + $this->assertSame(1, $this->posts()); + + Log::write('late'); + $this->queue->shutdown(); + + $this->assertSame(2, $this->posts()); + $records = $this->records(1); + $this->assertCount(1, $records); + $this->assertStringEndsWith('] late', $this->text($records[0])); + } + + public function testInvalidUtf8StillMakesValidJson(): void + { + $this->init('proj_ABC'); + Log::write("bad \xFF byte"); + + $this->queue->shutdown(); + + $this->assertStringEndsWith("] bad \u{FFFD} byte", $this->text($this->records(0)[0] ?? null)); + } + + public function testResetForgetsTheDecisionAndRemovesTheSink(): void + { + $this->init('proj_ABC'); + Diagnostics::reset(); + + Log::write('x'); + + $this->assertFalse(Diagnostics::enabled()); + $this->assertFalse($this->queue->hasWork()); + } + + private function init(string $projectId, bool $disabled = false, bool $testEnvironment = false): void + { + Diagnostics::init($projectId, $disabled, $this->queue, static fn(): bool => $testEnvironment, fn(): float => $this->now, static fn(): int => self::WALL_MILLIS); + } + + private function queueOn(HttpChannel $channel): EventQueue + { + return new EventQueue(transport: new RecordingTransport(), onEnqueue: function (): void { + ++$this->armed; + }, channel: $channel); + } + + /** The install ID a fresh `init('')` sends. */ + private function installIdOfANewProcess(): string + { + Diagnostics::reset(); + $this->http = new RecordingHttpChannel(); + $this->queue = $this->queueOn($this->http); + $this->init(''); + Log::write('one'); + $this->queue->shutdown(); + $first = $this->resource(0)[0] ?? null; + $this->assertSame('agentcat.install_id', JsonAssert::at($first, 'key')); + $id = JsonAssert::at($first, 'value', 'stringValue'); + $this->assertIsString($id); + + return $id; + } + + private function posts(): int + { + return count($this->http->requests); + } + + /** @return list the log records of request `$index` */ + private function records(int $index): array + { + $this->assertArrayHasKey($index, $this->http->requests); + $records = JsonAssert::at(Json::decode($this->http->requests[$index]->body), 'resourceLogs', 0, 'scopeLogs', 0, 'logRecords'); + $this->assertIsList($records); + + return $records; + } + + /** @return list the resource attributes of request `$index` */ + private function resource(int $index): array + { + $this->assertArrayHasKey($index, $this->http->requests); + $attributes = JsonAssert::at(Json::decode($this->http->requests[$index]->body), 'resourceLogs', 0, 'resource', 'attributes'); + $this->assertIsList($attributes); + + return $attributes; + } + + private function text(mixed $record): string + { + $text = JsonAssert::at($record, 'body', 'stringValue'); + $this->assertIsString($text); + + return $text; + } +} diff --git a/tests/Unit/Internal/Diagnostics/SeverityTest.php b/tests/Unit/Internal/Diagnostics/SeverityTest.php new file mode 100644 index 0000000..6f78389 --- /dev/null +++ b/tests/Unit/Internal/Diagnostics/SeverityTest.php @@ -0,0 +1,37 @@ + */ + public static function lines(): iterable + { + yield 'fail' => ['Failed to send event', [17, 'ERROR']]; + yield 'error' => ['Some error happened', [17, 'ERROR']]; + yield 'warning and fail' => ['Warning: Failed to track', [17, 'ERROR']]; + yield 'FAIL in capitals' => ['IT FAILED', [17, 'ERROR']]; + yield 'warning' => ['Warning: x', [13, 'WARN']]; + yield 'warning inside' => ['[x] Warning: y', [13, 'WARN']]; + yield 'lower-case warning' => ['warning: x', [9, 'INFO']]; + yield 'empty' => ['', [9, 'INFO']]; + yield 'WARN prefix' => ['WARN: x', [9, 'INFO']]; + yield 'ERROR prefix' => ['ERROR: x', [17, 'ERROR']]; + yield 'invalid UTF-8' => ["\xFF error", [17, 'ERROR']]; + } + + /** @param array{0: int, 1: string} $expected */ + #[DataProvider('lines')] + #[Group('ac-06-57')] + public function testTheSeverityComesFromTheWholeLine(string $line, array $expected): void + { + $this->assertSame($expected, Severity::of($line)); + } +} diff --git a/tests/Unit/Internal/Id/UuidV7Test.php b/tests/Unit/Internal/Id/UuidV7Test.php new file mode 100644 index 0000000..4199d65 --- /dev/null +++ b/tests/Unit/Internal/Id/UuidV7Test.php @@ -0,0 +1,120 @@ + 06 §6.7, and TS outputs computed with the vendored KSUID code */ + public static function vectors(): iterable + { + yield 'session KSUID' => ['ses_2yXlShd4ODDcHLYliGW9VgH3m1R', '01977375-f600-7179-87c6-b00767670c45']; + yield 'event KSUID' => ['evt_2yXlTL6bElM5Whm0HjT0Iz8LL0M', '01977376-0988-78fb-aa6b-36e16f376637']; + yield 'not a KSUID' => ['my-customer-task-id', '634902a7-d0c7-7220-91c6-0fa56fad8828']; + yield 'empty' => ['', 'f4c8996f-b924-73b0-8442-98fc1c149afb']; + yield 'overflows 160 bits' => ['ses_session45600000000000000000', '6c350afa-fa75-7081-b0c6-8ad3272daa6c']; + yield 'one prefix stripped' => ['abc_def_ghi', 'ae0466b7-1fe3-7984-a344-541280a247bb']; + yield '27 dashes parse as zero' => ['x_' . str_repeat('-', 27), '0145f680-b000-7d7a-8884-df06b0d17ac0']; + yield 'largest KSUID' => ['ses_aWgEPTl1tmebfsQzFP4bxwgy80V', '052df680-ac18-70fb-a3ad-b18287c0f14b']; + yield 'largest KSUID + 1' => ['ses_aWgEPTl1tmebfsQzFP4bxwgy80W', '69af7543-b7cc-7b64-a50a-27922b46ef70']; + yield 'upper-case prefix kept' => ['SES_2yXlShd4ODDcHLYliGW9VgH3m1R', '8bc15b2a-2661-7bac-a5b6-02eefdf24182']; + yield 'all zeros' => ['ses_000000000000000000000000000', '0145f680-b000-72dd-9660-4a400f542404']; + yield 'non-ASCII overflow' => ['ses_' . str_repeat('é', 27), '88417185-4ad5-75bc-9149-acad1fa28640']; + yield 'astral: 27 UTF-16 units' => ['evt_' . str_repeat('😀', 13) . 'a', '0145f680-b000-713a-b075-2ae9fa3e6e15']; + } + + #[DataProvider('vectors')] + #[Group('ac-06-7')] + public function testEveryVectorMatchesTypeScript(string $input, string $expected): void + { + $this->assertSame($expected, UuidV7::fromPrefixedId($input)); + } + + #[DataProvider('vectors')] + #[Group('ac-06-8')] + public function testVersionAndVariantNibbles(string $input, string $expected): void + { + $pattern = '/\A[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/'; + $this->assertMatchesRegularExpression($pattern, UuidV7::fromPrefixedId($input)); + $this->assertMatchesRegularExpression($pattern, $expected); + } + + #[Group('ac-06-8')] + public function testRandomInputsAreValidUuidV7(): void + { + for ($i = 0; $i < 50; ++$i) { + $this->assertMatchesRegularExpression('/\A[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/', UuidV7::fromPrefixedId(bin2hex(random_bytes(12)))); + } + } + + #[Group('ac-06-9')] + public function testNoClockIsRead(): void + { + $this->assertSame(UuidV7::fromPrefixedId('my-customer-task-id'), UuidV7::fromPrefixedId('my-customer-task-id')); + $this->assertNotSame(UuidV7::fromPrefixedId('task-a'), UuidV7::fromPrefixedId('task-b')); + } + + /** @return iterable 27 UTF-16 unit strings and `KSUID.parse(...).date.getTime()` of the vendored TypeScript code (node) */ + public static function typeScriptTimestamps(): iterable + { + yield ["\u{21}\u{2d}\u{2d}\u{21}\u{e9}\u{10000}0\u{2f}\u{20}\u{21}\u{ffff}\u{5b}\u{21}\u{7e}A\u{21}\u{1}\u{e9}0\u{e9}\u{e9}\u{2d}\u{1f600}\u{ffff}\u{5b}", 1_400_000_001_000]; + yield ["\u{21}a\u{21}\u{103ff}a\u{10ffff}\u{10ffff}a\u{7e}\u{3a}AzZ\u{10000}\u{1}1\u{5b}A\u{3a}19a\u{2d}", 1_400_000_001_000]; + yield ["a\u{10ffff}\u{1}\u{10000}Z9\u{20}\u{10ffff}\u{10000}A\u{2f}\u{20}\u{7e}\u{7e}9A\u{10000}9\u{10000}\u{2f}\u{2f}", 1_400_000_001_000]; + yield ["\u{e9}\u{103ff}\u{20}\u{10ffff}A0zA\u{20}1\u{10000}\u{ffff}\u{21}A\u{1f600}\u{2d}\u{1f600}\u{1}\u{10000}\u{5b}\u{2d}", 1_400_000_002_000]; + yield ["1\u{21}1\u{20}\u{2d}Z\u{103ff}\u{103ff}A\u{1}9\u{20}\u{10000}\u{1}\u{10ffff}zA1z\u{2d}\u{ff}a\u{ffff}", 1_400_000_006_000]; + yield ["\u{a1}\u{21}\u{2f}\u{21}0\u{a1}z\u{7e}\u{21}\u{1f600}\u{10ffff}\u{1f600}\u{21}\u{a1}\u{1f600}\u{1f600}\u{5b}\u{e9}aa\u{7e}Z", 1_400_000_012_000]; + yield ["0\u{3a}\u{7e}\u{7e}\u{2f}\u{a1}\u{2d}\u{21}\u{20}az\u{ffff}\u{2d}\u{10ffff}\u{1f600}\u{e9}\u{21}\u{2f}\u{e9}\u{7e}\u{10ffff}\u{ff}1\u{2d}", 1_400_000_032_000]; + yield ["\u{103ff}Z\u{103ff}\u{ffff}a\u{5b}\u{2d}\u{10ffff}\u{21}\u{10000}\u{e9}\u{2f}\u{ff}0\u{1f600}\u{10000}\u{2f}0\u{2d}1a", 1_400_000_114_000]; + yield ["\u{1f600}\u{103ff}\u{e9}\u{ff}aZ\u{20}\u{103ff}\u{103ff}\u{3a}\u{ff}\u{2d}0\u{21}\u{3a}\u{10ffff}\u{20}\u{10ffff}A\u{ff}\u{ffff}", 1_400_000_116_000]; + yield ["\u{3a}\u{e9}\u{10ffff}\u{103ff}\u{3a}\u{a1}9\u{2d}\u{5b}\u{2d}\u{5b}0\u{7e}\u{7e}\u{10000}\u{21}\u{3a}\u{5b}\u{5b}\u{ffff}\u{2d}\u{7e}\u{103ff}", 1_400_000_164_000]; + yield ["\u{5b}a\u{1f600}\u{2d}\u{ffff}0A9\u{10ffff}\u{ffff}A\u{2f}\u{1f600}\u{20}\u{20}\u{ff}\u{5b}\u{3a}\u{10000}aA99", 1_400_000_357_000]; + yield ["1\u{2d}\u{2d}\u{21}\u{ffff}\u{103ff}\u{21}\u{3a}\u{10ffff}\u{2d}\u{e9}\u{3a}\u{103ff}\u{21}\u{ffff}1\u{e9}Z\u{5b}\u{e9}\u{2f}Z\u{a1}\u{21}", 1_400_000_471_000]; + yield ["\u{7e}\u{ff}A\u{1f600}\u{10ffff}\u{103ff}\u{10000}\u{ffff}\u{2d}\u{20}\u{2d}\u{5b}z\u{2d}Z\u{20}\u{5b}a\u{e9}1\u{e9}\u{2d}1", 1_400_000_544_000]; + yield ["\u{3a}\u{5b}\u{3a}\u{21}\u{2d}1\u{10ffff}\u{2d}\u{ffff}z\u{21}\u{21}\u{10ffff}\u{7e}\u{ffff}\u{103ff}z\u{e9}\u{21}\u{5b}\u{21}\u{3a}\u{a1}A", 1_400_001_719_000]; + yield ["\u{103ff}\u{a1}\u{3a}\u{ff}\u{2f}ZZ\u{7e}\u{3a}\u{7e}0\u{1f600}\u{7e}\u{21}z\u{2d}\u{ffff}0\u{10ffff}\u{1f600}z\u{10ffff}", 1_400_007_089_000]; + yield ["\u{103ff}\u{10000}\u{2f}\u{1f600}\u{a1}\u{10000}Z\u{ff}\u{21}A\u{3a}\u{7e}\u{1}A\u{20}\u{10ffff}\u{1}\u{1}a\u{a1}\u{2d}Z", 1_400_007_203_000]; + yield ["\u{a1}\u{10000}z\u{e9}\u{10ffff}0\u{2d}\u{103ff}\u{20}\u{5b}11\u{ffff}z1\u{7e}\u{7e}\u{7e}0A\u{1}9\u{103ff}", 1_400_007_885_000]; + yield ["Z0\u{e9}aZ\u{103ff}\u{1f600}\u{7e}\u{2d}9\u{3a}z\u{a1}\u{a1}\u{a1}\u{e9}\u{3a}\u{7e}\u{1}\u{10000}A1\u{1}\u{ff}", 1_400_017_289_000]; + yield ["01\u{2d}\u{2d}\u{3a}\u{3a}Aa\u{21}\u{ffff}\u{3a}Z\u{10000}\u{21}A\u{3a}zZ\u{2d}\u{ff}1\u{ffff}9az\u{5b}", 1_400_029_085_000]; + yield ["\u{a1}\u{2d}\u{10ffff}z0z\u{1}Aa\u{3a}a\u{2f}0\u{10ffff}\u{ffff}AAA\u{103ff}\u{20}\u{21}\u{2d}\u{ffff}\u{5b}", 1_400_056_533_000]; + yield ["A\u{7e}\u{5b}\u{20}\u{ff}\u{3a}\u{10ffff}Zz\u{20}1\u{2d}\u{ff}\u{1f600}\u{a1}A9\u{5b}\u{2d}\u{ff}\u{ffff}\u{7e}\u{2f}9\u{3a}", 1_400_338_192_000]; + yield ["\u{1f600}0A\u{3a}\u{10000}\u{10000}\u{20}\u{a1}z9\u{7e}\u{3a}9z\u{7e}\u{1}\u{21}\u{7e}\u{20}\u{a1}\u{1f600}zz", 1_400_440_019_000]; + yield ["1\u{10ffff}\u{ffff}A\u{e9}\u{2d}\u{3a}AZ1\u{a1}A\u{10ffff}9\u{20}z0\u{3a}\u{10ffff}Z\u{3a}\u{2f}\u{5b}\u{21}", 1_400_456_570_000]; + yield ["9\u{ffff}\u{2f}a\u{20}A\u{5b}0\u{5b}\u{21}1\u{2d}\u{3a}\u{10ffff}\u{e9}\u{1f600}\u{ffff}1\u{21}\u{2d}\u{10ffff}0z\u{1}", 1_400_525_450_000]; + yield ["01\u{2d}\u{2d}1ZzA0\u{20}\u{e9}\u{7e}\u{20}\u{2d}\u{ff}\u{ffff}\u{1}\u{e9}\u{ffff}\u{2d}\u{1}\u{7e}\u{3a}\u{3a}\u{1}\u{ff}\u{5b}", 1_401_803_266_000]; + yield ["\u{ff}9\u{2d}9\u{1}Z\u{7e}Z\u{2f}\u{5b}\u{ffff}109\u{2f}\u{10000}z\u{7e}\u{103ff}\u{1}1\u{21}\u{2d}\u{e9}\u{1}", 1_405_938_626_000]; + yield ["\u{103ff}\u{e9}\u{2f}\u{ffff}z9\u{10ffff}\u{3a}\u{1}aAA\u{5b}\u{7e}\u{20}\u{ff}\u{a1}90\u{1f600}\u{a1}a\u{3a}\u{a1}", 1_427_252_587_000]; + yield ["\u{7e}\u{1f600}\u{21}00\u{2d}\u{5b}z9\u{7e}A\u{3a}a\u{21}\u{2f}\u{3a}0\u{a1}\u{e9}\u{21}9\u{21}\u{103ff}\u{20}9", 1_429_269_325_000]; + yield ["\u{7e}A\u{5b}\u{21}\u{20}\u{ffff}9\u{3a}\u{a1}\u{2f}\u{ffff}1\u{1}\u{1f600}a\u{e9}0\u{21}Z\u{1}0\u{e9}\u{ff}\u{5b}\u{2d}\u{e9}", 1_523_593_186_000]; + yield ["\u{e9}\u{ffff}0\u{2d}\u{a1}A\u{7e}Z\u{5b}\u{a1}\u{3a}\u{103ff}A\u{2f}\u{7e}\u{a1}Z9\u{ffff}\u{ffff}ZZ\u{5b}\u{ffff}0\u{20}", 3_728_962_873_000]; + yield ["a\u{10000}a\u{2f}\u{2d}a\u{0}\u{ffff}\u{1f600}\u{ffff}\u{ffff}a\u{2f}9\u{21}9\u{2d}Z\u{1}\u{10ffff}z1\u{1}0", 1_400_457_299_000]; + yield ["\u{10000}\u{2f}\u{2d}1\u{0}9\u{ffff}\u{21}19ZA\u{2d}\u{28}0\u{1}\u{103ff}9\u{10000}\u{20}A\u{10000}\u{21}", 1_400_439_533_000]; + yield ["\u{1f600}\u{20}Z\u{2f}\u{2d}\u{1}\u{1f600}\u{1f600}A00\u{0}A\u{2d}90a\u{2f}9\u{1f600}z\u{2f}\u{ffff}", 1_400_439_983_000]; + yield ["\u{1f600}\u{21}0Az\u{1f600}\u{0}a0Z0\u{10ffff}A\u{2f}A\u{ffff}\u{1}a\u{2f}\u{10ffff}\u{ffff}\u{10000}", 1_400_007_096_000]; + yield ["0\u{1f600}\u{2f}1\u{1}\u{2f}A\u{ffff}A\u{2f}9Zz\u{2d}\u{28}z\u{2f}\u{0}\u{21}Az\u{2d}\u{2f}z\u{2d}\u{2f}", 3_091_304_214_000]; + yield ["1\u{ffff}\u{2d}\u{ffff}\u{20}\u{ffff}91\u{ffff}\u{103ff}\u{28}\u{1f600}\u{103ff}\u{10000}0\u{2d}9A\u{2f}9A\u{2f}\u{1}", 1_400_008_277_000]; + } + + #[DataProvider('typeScriptTimestamps')] + #[Group('ac-06-7')] + public function testOddCharactersParseLikeTypeScript(string $encoded, int $expected): void + { + $this->assertSame($expected, UuidV7::ksuidMillis($encoded)); + } + + public function testTheKsuidTimestampFollowsTheTypeScriptParser(): void + { + $this->assertSame(1_749_988_800_000, UuidV7::ksuidMillis('2yXlShd4ODDcHLYliGW9VgH3m1R')); + $this->assertSame(5_694_967_295_000, UuidV7::ksuidMillis('aWgEPTl1tmebfsQzFP4bxwgy80V')); + $this->assertNull(UuidV7::ksuidMillis('aWgEPTl1tmebfsQzFP4bxwgy80W'), 'overflow throws in TS'); + $this->assertNull(UuidV7::ksuidMillis('zzzzzzzzzzzzzzzzzzzzzzzzzzz')); + $this->assertNull(UuidV7::ksuidMillis('2yXlShd4ODDcHLYliGW9VgH3m1'), '26 characters'); + $this->assertSame(1_400_000_000_000, UuidV7::ksuidMillis(str_repeat('-', 27)), 'no character validation'); + } +} diff --git a/tests/Unit/Internal/Logging/LogLineTest.php b/tests/Unit/Internal/Logging/LogLineTest.php new file mode 100644 index 0000000..1e6ab10 --- /dev/null +++ b/tests/Unit/Internal/Logging/LogLineTest.php @@ -0,0 +1,49 @@ +assertSame('[2026-10-16T15:04:56.821Z] ' . LogLine::prefix() . ' m', $line); + } + + public function testAnotherTimeZoneIsConvertedToUtc(): void + { + $line = LogLine::format('m', new \DateTimeImmutable('2026-10-16T17:04:56.821+02:00')); + + $this->assertSame('[2026-10-16T15:04:56.821Z] ' . LogLine::prefix() . ' m', $line); + } + + public function testWithoutATimeTheLineCarriesTheCurrentTime(): void + { + $before = new \DateTimeImmutable('-1 second'); + $line = LogLine::format('now'); + $after = new \DateTimeImmutable('+1 second'); + + $this->assertMatchesRegularExpression('/\A\[(\d{4}-\d\d\-\d\dT\d\d:\d\d:\d\d\.\d{3})Z\] ' . preg_quote(LogLine::prefix(), '/') . ' now\z/', $line); + $stamp = new \DateTimeImmutable(substr($line, 1, 24)); + $this->assertGreaterThanOrEqual($before, $stamp); + $this->assertLessThanOrEqual($after, $stamp); + } + + #[Group('ac-03-38')] + public function testThePrefixNamesTheSdkPhpAndMcpVersionsOnce(): void + { + $prefix = LogLine::prefix(); + + $this->assertMatchesRegularExpression('/\A\[sdk=\S+ php=\d+\.\d+\.\d+\S* mcp=(v?\d\S*|dev-\S+|unknown)\]\z/', $prefix); + $this->assertSame(sprintf('[sdk=%s php=%s mcp=%s]', SdkVersion::get(), PHP_VERSION, SdkVersion::installed('mcp/sdk') ?? 'unknown'), $prefix); + $this->assertSame($prefix, LogLine::prefix()); + } +} diff --git a/tests/Unit/Internal/Logging/LogTest.php b/tests/Unit/Internal/Logging/LogTest.php index 5224192..e2ce78f 100644 --- a/tests/Unit/Internal/Logging/LogTest.php +++ b/tests/Unit/Internal/Logging/LogTest.php @@ -4,9 +4,12 @@ namespace AgentCat\Tests\Unit\Internal\Logging; +use AgentCat\Internal\Logging\FileLogger; use AgentCat\Internal\Logging\Log; +use AgentCat\Internal\Logging\LogLine; use AgentCat\Tests\Support\MemoryLogger; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; use Psr\Log\AbstractLogger; @@ -255,4 +258,155 @@ public function testResetFallsBackToFileLoggerInHome(): void rmdir($home); } } + + public function testTheSinkGetsTheLineBeforeTheLogger(): void + { + /** @var \ArrayObject $order */ + $order = new \ArrayObject(); + Log::setLogger(new class ($order) extends AbstractLogger { + /** @param \ArrayObject $order */ + public function __construct(private readonly \ArrayObject $order) {} + + /** @param mixed[] $context */ + #[\Override] + public function log($level, string|\Stringable $message, array $context = []): void + { + $this->order[] = 'logger: ' . $message; + } + }); + Log::setDiagnosticsSink(static function (string $line) use ($order): void { + $order[] = $line; + }); + + Log::write('hello'); + + $this->assertSame([LogLine::prefix() . ' hello', 'logger: hello'], $this->withoutTime($order, 1)); + } + + public function testEveryLevelReachesTheSink(): void + { + Log::setLogger(new MemoryLogger()); + $lines = $this->recordSink(); + + Log::error('a'); + Log::warning('b'); + Log::info('c'); + Log::write('d'); + + $this->assertSame([LogLine::prefix() . ' a', LogLine::prefix() . ' b', LogLine::prefix() . ' c', LogLine::prefix() . ' d'], $this->withoutTime($lines)); + } + + #[Group('ac-06-60')] + public function testAThrowingSinkNeverStopsTheLogger(): void + { + $logger = new MemoryLogger(); + Log::setLogger($logger); + Log::setDiagnosticsSink(static function (): never { + throw new \RuntimeException('sink'); + }); + + Log::write('hello'); + + $this->assertSame(['hello'], $logger->messages()); + } + + #[Group('ac-03-36')] + public function testWithoutAHomeTheSinkStillGetsTheLine(): void + { + Log::setLogger(new FileLogger(null, static fn(): ?string => null)); + $lines = $this->recordSink(); + + Log::write('hello'); + + $this->assertSame([LogLine::prefix() . ' hello'], $this->withoutTime($lines)); + $this->assertMatchesRegularExpression('/\A\[sdk=\S+ php=\S+ mcp=\S+\]\z/', LogLine::prefix()); + } + + /** D38: lines that go to the PSR-3 logger of the tracked server reach the sink too. */ + public function testAnAdoptedLoggerDoesNotHideLinesFromTheSink(): void + { + $adopted = new MemoryLogger(); + Log::adopt($adopted); + $lines = $this->recordSink(); + + Log::write('one'); + Log::write('two'); + + $this->assertSame(['one', 'two'], $adopted->messages()); + $this->assertSame([LogLine::prefix() . ' one', LogLine::prefix() . ' two'], $this->withoutTime($lines)); + } + + /** Spec conflict 9: while `AgentCat::fake()` diverts the log, diagnostics get nothing. */ + public function testADivertedLineNeverReachesTheSink(): void + { + Log::setLogger(new MemoryLogger()); + $lines = $this->recordSink(); + $fake = new MemoryLogger(); + + Log::divert($fake); + Log::write('diverted'); + Log::endDiversion(); + Log::write('after'); + + $this->assertSame(['diverted'], $fake->messages()); + $this->assertSame([LogLine::prefix() . ' after'], $this->withoutTime($lines)); + } + + public function testASecondSinkReplacesTheFirstAndNullRemovesIt(): void + { + Log::setLogger(new MemoryLogger()); + $first = $this->recordSink(); + $second = $this->recordSink(); + + Log::write('one'); + Log::setDiagnosticsSink(null); + Log::write('two'); + + $this->assertSame([], $this->withoutTime($first)); + $this->assertSame([LogLine::prefix() . ' one'], $this->withoutTime($second)); + } + + public function testResetRemovesTheSink(): void + { + $lines = $this->recordSink(); + Log::reset(); + Log::setLogger(new MemoryLogger()); + + Log::write('hello'); + + $this->assertSame([], $this->withoutTime($lines)); + } + + /** @return \ArrayObject the lines the new sink receives */ + private function recordSink(): \ArrayObject + { + /** @var \ArrayObject $lines */ + $lines = new \ArrayObject(); + Log::setDiagnosticsSink(static function (string $line) use ($lines): void { + $lines[] = $line; + }); + + return $lines; + } + + /** + * The first `$timed` lines (all by default) with their `[] ` stamp checked and removed; the rest as is. + * + * @param \ArrayObject $lines + * + * @return list + */ + private function withoutTime(\ArrayObject $lines, ?int $timed = null): array + { + $out = []; + foreach (array_values($lines->getArrayCopy()) as $index => $line) { + if ($timed === null || $index < $timed) { + $this->assertMatchesRegularExpression('/\A\[\d{4}-\d\d\-\d\dT\d\d:\d\d:\d\d\.\d{3}Z\] /', $line); + $line = substr($line, 27); + } + $out[] = $line; + } + + return $out; + } } diff --git a/tests/Unit/Internal/Support/ValuesTest.php b/tests/Unit/Internal/Support/ValuesTest.php index d6b730c..f815d07 100644 --- a/tests/Unit/Internal/Support/ValuesTest.php +++ b/tests/Unit/Internal/Support/ValuesTest.php @@ -21,6 +21,13 @@ public function testIsoDate(): void $this->assertSame('2026-10-16T12:00:00.123Z', Values::isoDate(\DateTime::createFromImmutable($date))); } + public function testEpochMillisFloorsBeforeTheEpochToo(): void + { + $this->assertSame(1749988805123, Values::epochMillis(new \DateTimeImmutable('2025-06-15T12:00:05.123Z'))); + $this->assertSame(-500, Values::epochMillis(new \DateTimeImmutable('1969-12-31T23:59:59.500Z'))); + $this->assertSame(-1000, Values::epochMillis(new \DateTimeImmutable('1969-12-31T23:59:59.000Z'))); + } + public function testEnumMarker(): void { $this->assertSame('[Enum: AgentCat\Tests\Support\Samples\UnitStatus::Active]', Values::enumMarker(UnitStatus::Active)); diff --git a/tests/Unit/Internal/Telemetry/Datadog/DatadogPayloadTest.php b/tests/Unit/Internal/Telemetry/Datadog/DatadogPayloadTest.php new file mode 100644 index 0000000..3ee2a49 --- /dev/null +++ b/tests/Unit/Internal/Telemetry/Datadog/DatadogPayloadTest.php @@ -0,0 +1,187 @@ +assertStringEndsWith('agentcat.bad_key_x:a_b', $this->logString(ExporterFixtures::event(), 'ddtags')); + $this->assertSame('a_b_c_d', DatadogPayload::tagKey("A\u{00A0}B,,C:D")); + $this->assertSame('x_y', DatadogPayload::tagKey("X \t\n Y")); + $this->assertSame('ünï_x', DatadogPayload::tagKey('ÜNÏ:x')); + $this->assertSame('?', DatadogPayload::tagKey("\xFF")); + } + + #[Group('ac-06-28')] + public function testAZeroDurationHasNoDurationSeries(): void + { + $event = ExporterFixtures::event(); + $event->duration = 0; + + $this->assertSame(1, self::requests($event)['series']); + $this->assertSame(0, $this->log($event, 'mcp', 'duration_ms')); + $this->assertSame(['mcp.events.count'], $this->metrics($event)); + } + + #[Group('ac-06-29')] + public function testASessionlessEventGetsARandomDecimalTraceId(): void + { + $event = ExporterFixtures::event(); + $event->sessionId = ''; + + $this->assertSame('', $this->log($event, 'mcp', 'session_id')); + $this->assertMatchesRegularExpression('/\A[0-9]{1,20}\z/', $this->logString($event, 'dd', 'trace_id')); + } + + public function testTheSiteBecomesTheIntakeHosts(): void + { + $payload = static fn(string $site): DatadogPayload => DatadogPayload::of(new DatadogExporter('k', $site, 's')); + + $this->assertSame('https://http-intake.logs.datadoghq.eu/api/v2/logs', $payload('https://datadoghq.eu/')->logsUrl); + $this->assertSame('https://api.datadoghq.eu/api/v1/series', $payload('https://datadoghq.eu/')->metricsUrl); + $this->assertSame('https://http-intake.logs.us3.datadoghq.com/api/v2/logs', $payload('us3.datadoghq.com')->logsUrl); + $this->assertSame('https://api.us3.datadoghq.com/api/v1/series', $payload('us3.datadoghq.com')->metricsUrl); + $this->assertSame('https://http-intake.logs.datadoghq.com//api/v2/logs', $payload('http://datadoghq.com//')->logsUrl); + $this->assertSame('https://api.datadoghq.com//api/v1/series', $payload('datadoghq.com//')->metricsUrl); + $this->assertSame('https://api.x.http://y/api/v1/series', $payload('x.http://y')->metricsUrl); + } + + public function testANonStringTagValueIsATypeError(): void + { + $event = ExporterFixtures::event(); + $event->tags = ['n' => 5]; // @phpstan-ignore assign.propertyType (a redactEvent hook can put a non-string here) + $error = Thrown::by(JsError::class, static fn(): mixed => self::requests($event)); + + $this->assertSame('TypeError', $error->name); + $this->assertSame('value.replace is not a function', $error->getMessage()); + } + + #[Group('ac-06-46')] + public function testTheErrorMessageAndEmptyMaps(): void + { + $event = ExporterFixtures::event(); + $event->isError = true; + $event->error = 'x'; + $event->tags = []; + $event->properties = []; + + $this->assertSame('x', $this->log($event, 'error', 'message')); + $this->assertEquals(new \stdClass(), $this->log($event, 'mcp', 'tags')); + $this->assertEquals(new \stdClass(), $this->log($event, 'mcp', 'properties')); + $this->assertSame('error', $this->log($event, 'status')); + + $event->error = (object) ['message' => 'boom']; + $this->assertSame('{"message":"boom"}', $this->log($event, 'error', 'message')); + + $event->error = null; + $this->assertSame(['message', 'service', 'ddsource', 'ddtags', 'timestamp', 'status', 'dd', 'mcp'], $this->keys($this->log($event))); + $this->assertNotContains('error', $this->keys($this->log($event, 'mcp'))); + } + + public function testNullFieldsAreLeftOutOfTheMcpObject(): void + { + $event = ExporterFixtures::customEvent(); + + $this->assertSame(['session_id', 'event_id', 'event_type', 'user_intent', 'client_name', 'client_version', 'server_name', 'server_version', 'is_error'], $this->keys($this->log($event, 'mcp'))); + $this->assertSame('agentcat:custom - unknown', $this->log($event, 'message')); + } + + public function testEnvServiceAndFlagsShapeTheTags(): void + { + $event = ExporterFixtures::event(); + $event->tags = null; + $event->resourceName = null; + $event->isError = false; + $event->eventType = ''; + $requests = DatadogPayload::of(new DatadogExporter('k', 'datadoghq.com', 'svc', 'prod'))->requests($event); + $log = Json::decode($requests['logs']->body); + + $this->assertSame('env:prod,source:agentcat', JsonAssert::at($log, 0, 'ddtags')); + $this->assertSame('unknown - unknown', JsonAssert::at($log, 0, 'message')); + $this->assertSame('info', JsonAssert::at($log, 0, 'status')); + $this->assertSame(['DD-API-KEY' => 'k', 'Content-Type' => 'application/json'], $requests['metrics']->headers); + $this->assertSame(['service:svc', 'env:prod'], JsonAssert::at(Json::decode($requests['metrics']->body), 'series', 0, 'tags')); + + $noEnv = DatadogPayload::of(new DatadogExporter('k', 'datadoghq.com', 'svc', ''))->requests(ExporterFixtures::event()); + $this->assertStringContainsString('"ddtags":"event_type:mcp:tools.call,resource:get_weather,source:', $noEnv['logs']->body); + } + + public function testSecondsRoundDownLikeMathFloor(): void + { + $event = ExporterFixtures::event(); + $event->timestamp = new \DateTimeImmutable('1969-12-31T23:59:59.500Z'); + + $this->assertSame(-1, $this->series($event, 0, 'points', 0, 0)); + $this->assertSame(-500, $this->log($event, 'timestamp')); + + $event->timestamp = new \DateTimeImmutable('1969-12-31T23:59:59.000Z'); + $this->assertSame(-1, $this->series($event, 0, 'points', 0, 0)); + + $event->timestamp = new \DateTimeImmutable('2025-06-15T12:00:05.999Z'); + $this->assertSame(1749988805, $this->series($event, 0, 'points', 0, 0)); + } + + public function testErrorEventsGetAnErrorCountSeries(): void + { + $this->assertSame(3, self::requests(ExporterFixtures::errorEvent())['series']); + $this->assertSame(['mcp.events.count', 'mcp.event.duration', 'mcp.errors.count'], $this->metrics(ExporterFixtures::errorEvent())); + $this->assertSame(1, self::requests(ExporterFixtures::customEvent())['series']); + } + + /** @return array{logs: HttpRequest, metrics: HttpRequest, series: int} */ + private static function requests(Event $event): array + { + return DatadogPayload::of(new DatadogExporter('ddkey', 'datadoghq.com', 'svc'))->requests($event); + } + + private function log(Event $event, string|int ...$path): mixed + { + return JsonAssert::at(Json::decode(self::requests($event)['logs']->body), 0, ...$path); + } + + private function logString(Event $event, string|int ...$path): string + { + $value = $this->log($event, ...$path); + $this->assertIsString($value); + + return $value; + } + + private function series(Event $event, string|int ...$path): mixed + { + return JsonAssert::at(Json::decode(self::requests($event)['metrics']->body), 'series', ...$path); + } + + /** @return list */ + private function metrics(Event $event): array + { + $series = $this->series($event); + $this->assertIsArray($series); + + return array_map(static fn(mixed $one): mixed => JsonAssert::at($one, 'metric'), array_values($series)); + } + + /** @return list */ + private function keys(mixed $object): array + { + $this->assertInstanceOf(\stdClass::class, $object); + + return array_map(strval(...), array_keys(get_object_vars($object))); + } +} diff --git a/tests/Unit/Internal/Telemetry/DatadogExportTest.php b/tests/Unit/Internal/Telemetry/DatadogExportTest.php new file mode 100644 index 0000000..26a3ae0 --- /dev/null +++ b/tests/Unit/Internal/Telemetry/DatadogExportTest.php @@ -0,0 +1,128 @@ +logger = new MemoryLogger(); + Log::setLogger($this->logger); + } + + #[\Override] + protected function tearDown(): void + { + Log::reset(); + } + + #[Group('ac-06-25')] + public function testASuccessfulExportPostsTheLogThenTheMetricsAndLogsBothStatuses(): void + { + $http = new RecordingHttpChannel([], new HttpResponse(202, 'Accepted', '{}')); + $event = ExporterFixtures::event(); + + $this->export($http, $event); + + $expected = DatadogPayload::of($this->config())->requests($event); + $this->assertSame([self::LOGS, self::METRICS], $http->urls()); + $this->assertCount(2, $http->requests); + $this->assertSame($expected['logs']->headers, $http->requests[0]->headers); + $this->assertSame($expected['logs']->body, $http->requests[0]->body); + $this->assertSame($expected['metrics']->headers, $http->requests[1]->headers); + $this->assertSame($expected['metrics']->body, $http->requests[1]->body); + $this->assertSame([ + ...self::FIRST_LINES, + 'DatadogExporter: Sending 2 metric series', + 'Datadog logs success - Status: 202', + 'Datadog metrics success - Status: 202', + ], $this->logger->messages()); + } + + #[Group('ac-06-26')] + public function testAnErrorEventCountsItsThirdSeries(): void + { + $this->export(new RecordingHttpChannel(), ExporterFixtures::errorEvent()); + + $this->assertContains('DatadogExporter: Sending 3 metric series', $this->logger->messages()); + } + + /** @param list $responses */ + #[DataProvider('failures')] + #[Group('ac-06-30')] + public function testAFailedRequestLogsItsLineAndTheOtherIsStillSent(array $responses, string $logs, string $metrics): void + { + $http = new RecordingHttpChannel($responses); + + $this->export($http, ExporterFixtures::event()); + + $this->assertSame([self::LOGS, self::METRICS], $http->urls()); + $this->assertSame([...self::FIRST_LINES, 'DatadogExporter: Sending 2 metric series', $logs, $metrics], $this->logger->messages()); + } + + /** @return iterable, string, string}> */ + public static function failures(): iterable + { + yield 'logs network error' => [[HttpResponse::failed('cURL error 6: Could not resolve host')], 'Datadog logs network error: cURL error 6: Could not resolve host', 'Datadog metrics success - Status: 200']; + yield 'logs 403' => [[new HttpResponse(403, 'Forbidden', 'denied')], 'Datadog logs failed - Status: 403', 'Datadog metrics success - Status: 200']; + yield 'metrics network error' => [[new HttpResponse(200, 'OK', ''), HttpResponse::failed('cURL error 28: Operation timed out')], 'Datadog logs success - Status: 200', 'Datadog metrics network error: cURL error 28: Operation timed out']; + yield 'metrics 500' => [[new HttpResponse(200, 'OK', ''), new HttpResponse(500, 'Internal Server Error', '')], 'Datadog logs success - Status: 200', 'Datadog metrics failed - Status: 500']; + } + + /** Spec conflict 10: TypeScript's payload building throws on a non-string tag value, before any request. */ + public function testANonStringTagValueSendsNothingAndRejectsWithTheTypeError(): void + { + $http = new RecordingHttpChannel(); + $event = ExporterFixtures::event(); + $event->tags = ['n' => 5]; // @phpstan-ignore assign.propertyType (a redactEvent hook can put a non-string there) + + $this->export($http, $event); + + $this->assertSame([], $http->requests); + $this->assertSame([ + 'Initialized telemetry exporter: dd', + 'DatadogExporter: Sending event immediately to Datadog', + 'Telemetry export failed for dd: value.replace is not a function', + ], $this->logger->messages()); + } + + private function export(RecordingHttpChannel $http, Event $event): void + { + $manager = TelemetryManager::fromConfigs(['dd' => $this->config()], $http); + $manager->export($event); + $http->pump(); + } + + private function config(): DatadogExporter + { + return new DatadogExporter('k', 'datadoghq.com', 'svc'); + } +} diff --git a/tests/Unit/Internal/Telemetry/ExportErrorsTest.php b/tests/Unit/Internal/Telemetry/ExportErrorsTest.php new file mode 100644 index 0000000..276c87f --- /dev/null +++ b/tests/Unit/Internal/Telemetry/ExportErrorsTest.php @@ -0,0 +1,44 @@ +assertSame('RangeError', $error->name); + $this->assertSame('too big', $error->getMessage()); + $this->assertSame('RangeError: too big', ExportErrors::text($error)); + $this->assertSame('too big', ExportErrors::message($error)); + } + + public function testAnotherThrowableUsesTheErrorText(): void + { + $error = new \InvalidArgumentException('bad'); + + $this->assertSame('InvalidArgumentException: bad', ExportErrors::text($error)); + $this->assertSame('bad', ExportErrors::message($error)); + } + + public function testAThrowableWithANonStringMessageNeverThrows(): void + { + $error = new class ('x') extends \RuntimeException { + public function __construct(string $message) + { + parent::__construct($message); + $this->message = ['not', 'a string']; // @phpstan-ignore assign.propertyType (a legacy subclass can store any value) + } + }; + + $this->assertSame('Array', ExportErrors::message($error)); + $this->assertStringEndsWith(': Array', ExportErrors::text($error)); + } +} diff --git a/tests/Unit/Internal/Telemetry/ExportJsonTest.php b/tests/Unit/Internal/Telemetry/ExportJsonTest.php new file mode 100644 index 0000000..1660765 --- /dev/null +++ b/tests/Unit/Internal/Telemetry/ExportJsonTest.php @@ -0,0 +1,76 @@ +assertSame('{"t":1749988805.123,"l":0.15}', ExportJson::encode(['t' => 1749988805.123, 'l' => 150 / 1000])); + $this->assertSame('17', ini_get('serialize_precision')); + } finally { + ini_set('serialize_precision', (string) $before); + } + } + + public function testSerializePrecisionIsRestoredAfterAFailure(): void + { + $before = ini_get('serialize_precision'); + ini_set('serialize_precision', '14'); + + try { + $loop = []; + $loop['self'] = &$loop; + Thrown::by(\UnexpectedValueException::class, static fn(): string => ExportJson::encode($loop)); + $this->assertSame('14', ini_get('serialize_precision')); + } finally { + ini_set('serialize_precision', (string) $before); + } + } + + public function testJsonStringifyRulesForUnencodableValues(): void + { + $encoded = ExportJson::encode(['u' => "caf\xE9", 'n' => NAN, 'i' => INF, 'f' => static fn(): int => 1, 'l' => [static fn(): int => 1]]); + + $this->assertSame("{\"u\":\"caf\u{FFFD}\",\"n\":null,\"i\":null,\"l\":[null]}", $encoded); + } + + public function testNothingIsEscapedThatJsonStringifyLeavesAlone(): void + { + $this->assertSame("{\"a\":\"/x\",\"b\":\"é\",\"c\":\"\u{2028}\"}", ExportJson::encode(['a' => '/x', 'b' => 'é', 'c' => "\u{2028}"])); + $this->assertSame('1', ExportJson::encode(1.0)); + } + + public function testASelfReferencingValueThrows(): void + { + $node = new \stdClass(); + $node->self = $node; + + Thrown::by(\UnexpectedValueException::class, static fn(): string => ExportJson::encode($node)); + } + + public function testAnEmptyArrayInAMapPositionBecomesAnEmptyObject(): void + { + $map = ExportJson::map([]); + + $this->assertInstanceOf(\stdClass::class, $map); + $this->assertSame([], get_object_vars($map)); + $this->assertSame('{}', ExportJson::encode($map)); + $this->assertSame(['a' => 1], ExportJson::map(['a' => 1])); + $this->assertNull(ExportJson::map(null)); + $this->assertSame('x', ExportJson::map('x')); + $this->assertSame(0, ExportJson::map(0)); + } +} diff --git a/tests/Unit/Internal/Telemetry/ExporterConfigsTest.php b/tests/Unit/Internal/Telemetry/ExporterConfigsTest.php new file mode 100644 index 0000000..f0c1475 --- /dev/null +++ b/tests/Unit/Internal/Telemetry/ExporterConfigsTest.php @@ -0,0 +1,141 @@ +logger = new MemoryLogger(); + Log::setLogger($this->logger); + } + + #[\Override] + protected function tearDown(): void + { + Log::reset(); + } + + public function testValidEntriesAreKeptInOrderWithTheirKeys(): void + { + $typed = new SentryExporter('https://k@h/1'); + + $configs = ExporterConfigs::fromArray([ + 'b' => ['type' => 'posthog', 'apiKey' => 'k'], + 5 => ['type' => 'datadog', 'apiKey' => 'k', 'site' => 's', 'service' => 'v', 'env' => null], + 'a' => $typed, + 'c' => ['type' => 'otlp', 'endpoint' => 'http://h', 'headers' => ['x' => 'y']], + ]); + + $this->assertSame(['b', 5, 'a', 'c'], array_keys($configs)); + $this->assertEquals([ + 'b' => new PostHogExporter('k'), + 5 => new DatadogExporter('k', 's', 'v'), + 'a' => $typed, + 'c' => new OtlpExporter('http://h', ['x' => 'y']), + ], $configs); + $this->assertContains($typed, $configs); + $this->assertSame([], $this->logger->messages()); + } + + public function testEveryTypedConfigObjectPassesThroughAsIs(): void + { + $objects = [ + new OtlpExporter('http://h'), + new DatadogExporter('k', 's', 'v'), + new SentryExporter('d'), + new PostHogExporter('k'), + ]; + + $this->assertSame($objects, ExporterConfigs::fromArray($objects)); + $this->assertSame([], $this->logger->messages()); + } + + public function testEveryHeaderIsKept(): void + { + $configs = ExporterConfigs::fromArray(['a' => ['type' => 'otlp', 'endpoint' => 'e', 'headers' => ['x' => '1', 'y' => '2']]]); + + $this->assertEquals(['a' => new OtlpExporter('e', ['x' => '1', 'y' => '2'])], $configs); + } + + public function testAnEmptyListGivesAnEmptyList(): void + { + $this->assertSame([], ExporterConfigs::fromArray([])); + } + + public function testAnUnknownTypeIsLoggedWithItsValue(): void + { + $configs = ExporterConfigs::fromArray(['a' => ['type' => 'bogus'], 'b' => ['type' => 7], 'c' => ['type' => null]]); + + $this->assertSame([], $configs); + $this->assertSame(['Unknown exporter type: bogus', 'Unknown exporter type: 7', 'Unknown exporter type: null'], $this->logger->messages()); + } + + public function testAMissingTypeIsLoggedAsUndefined(): void + { + $this->assertSame([], ExporterConfigs::fromArray(['a' => ['endpoint' => 'x']])); + $this->assertSame(['Unknown exporter type: undefined'], $this->logger->messages()); + } + + public function testAMistypedFieldSkipsOnlyThatEntry(): void + { + $configs = ExporterConfigs::fromArray([ + 'a' => ['type' => 'sentry', 'dsn' => 5], + 'b' => ['type' => 'sentry', 'dsn' => 'd', 'release' => 1], + 'c' => ['type' => 'otlp', 'endpoint' => 'e', 'headers' => 'x'], + 'd' => ['type' => 'posthog', 'apiKey' => 'k', 'enableAITracing' => 1], + 'e' => ['type' => 'otlp', 'endpoint' => 'e'], + ]); + + $this->assertSame(['e'], array_keys($configs)); + $this->assertSame([ + 'Failed to initialize exporter a: InvalidArgumentException: exporter option "dsn" must be string, got int', + 'Failed to initialize exporter b: InvalidArgumentException: exporter option "release" must be string or null, got int', + 'Failed to initialize exporter c: InvalidArgumentException: exporter option "headers" must be array, got string', + 'Failed to initialize exporter d: InvalidArgumentException: exporter option "enableAITracing" must be bool, got int', + ], $this->logger->messages()); + } + + public function testANonArrayEntryIsLogged(): void + { + $this->assertSame([], ExporterConfigs::fromArray([3 => null, 'x' => new \stdClass()])); + $this->assertSame([ + 'Failed to initialize exporter 3: InvalidArgumentException: exporter configuration must be an array, got null', + 'Failed to initialize exporter x: InvalidArgumentException: exporter configuration must be an array, got stdClass', + ], $this->logger->messages()); + } + + public function testAnUnknownKeyIsWarnedAboutAndTheEntryIsKept(): void + { + $configs = ExporterConfigs::fromArray([ + 'a' => ['type' => 'otlp', 'endpoint' => 'e', 'timeout' => 5], + 0 => ['type' => 'posthog', 'apiKey' => 'k', 'dsn' => 'x'], + ]); + + $this->assertSame(['a', 0], array_keys($configs)); + $this->assertSame([ + 'WARN: Unknown AgentCat option "exporters.a.timeout" ignored', + 'WARN: Unknown AgentCat option "exporters.0.dsn" ignored', + ], $this->logger->messages()); + } + + public function testAHeaderWithANonStringValueIsRejected(): void + { + $this->assertSame([], ExporterConfigs::fromArray(['a' => ['type' => 'otlp', 'endpoint' => 'e', 'headers' => ['x' => 'y', 'z' => null]]])); + $this->assertSame(['Failed to initialize exporter a: InvalidArgumentException: exporter option "headers" must be array, got array'], $this->logger->messages()); + } +} diff --git a/tests/Unit/Internal/Telemetry/Otlp/OtlpPayloadTest.php b/tests/Unit/Internal/Telemetry/Otlp/OtlpPayloadTest.php new file mode 100644 index 0000000..dec36f4 --- /dev/null +++ b/tests/Unit/Internal/Telemetry/Otlp/OtlpPayloadTest.php @@ -0,0 +1,198 @@ +assertSame('http://h:4318/v1/traces', OtlpPayload::of(new OtlpExporter('http://h:4318/v1/traces//'))->url); + $this->assertSame('http://h:4318/v1/traces', OtlpPayload::of(new OtlpExporter('http://h:4318'))->url); + $this->assertSame('http://h:4318/v1/traces', OtlpPayload::of(new OtlpExporter('http://h:4318/'))->url); + $this->assertSame('http://h/x/v1/traces', OtlpPayload::of(new OtlpExporter('http://h/x'))->url); + } + + #[Group('ac-06-19')] + public function testAUserContentTypeReplacesTheDefault(): void + { + $payload = OtlpPayload::of(new OtlpExporter('http://h', ['Content-Type' => 'application/x-protobuf', 'x-a' => 'b'])); + + $this->assertSame(['Content-Type' => 'application/x-protobuf', 'x-a' => 'b'], $payload->headers); + $this->assertSame(['Content-Type' => 'application/json'], OtlpPayload::of(new OtlpExporter('http://h'))->headers); + } + + #[Group('ac-06-21')] + public function testAZeroOrMissingDurationEndsWhereTheSpanStarts(): void + { + foreach ([0, null, 0.0] as $duration) { + $event = ExporterFixtures::event(); + $event->duration = $duration; + $this->assertSame('1749988805123000000', $this->span($event, 'startTimeUnixNano')); + $this->assertSame('1749988805123000000', $this->span($event, 'endTimeUnixNano')); + } + } + + #[Group('ac-06-22')] + public function testFalsyAttributesAreLeftOut(): void + { + $event = ExporterFixtures::event(); + $event->userIntent = ''; + $event->tags = ['empty' => '', 'full' => 'x']; + $keys = array_keys(self::attributes($event)); + + $this->assertNotContains('mcp.user_intent', $keys); + $this->assertNotContains('agentcat.tag.empty', $keys); + $this->assertContains('agentcat.tag.full', $keys); + } + + #[Group('ac-06-22')] + public function testPropertiesAreAJsonStringAttribute(): void + { + $event = ExporterFixtures::event(); + $event->properties = new \stdClass(); + $this->assertSame(['agentcat.properties' => '{}'], array_intersect_key($this->attributes($event), ['agentcat.properties' => 1])); + + $event->properties = ['a' => 1]; + $this->assertSame(['agentcat.properties' => '{"a":1}'], array_intersect_key($this->attributes($event), ['agentcat.properties' => 1])); + + $event->properties = null; + $this->assertArrayNotHasKey('agentcat.properties', $this->attributes($event)); + } + + #[Group('ac-06-24')] + public function testAFractionalDurationIsARangeError(): void + { + $event = ExporterFixtures::event(); + $event->duration = 1.5; + $error = Thrown::by(JsError::class, static fn(): mixed => OtlpPayload::of(new OtlpExporter('http://h'))->request($event)); + + $this->assertSame('RangeError', $error->name); + $this->assertSame('The number 1.5 cannot be converted to a BigInt because it is not an integer', $error->getMessage()); + + $event->duration = INF; + $error = Thrown::by(JsError::class, static fn(): mixed => OtlpPayload::of(new OtlpExporter('http://h'))->request($event)); + $this->assertSame('The number Infinity cannot be converted to a BigInt because it is not an integer', $error->getMessage()); + + $event->duration = -INF; + $error = Thrown::by(JsError::class, static fn(): mixed => OtlpPayload::of(new OtlpExporter('http://h'))->request($event)); + $this->assertSame('The number -Infinity cannot be converted to a BigInt because it is not an integer', $error->getMessage()); + } + + #[Group('ac-06-24')] + public function testNanIsFalsyAndAWholeFloatIsExact(): void + { + $event = ExporterFixtures::event(); + $event->duration = NAN; + $this->assertSame('1749988805123000000', $this->span($event, 'endTimeUnixNano')); + + $event->duration = 150.0; + $this->assertSame('1749988805273000000', $this->span($event, 'endTimeUnixNano')); + + $event->duration = 150; + $this->assertSame('1749988805273000000', $this->span($event, 'endTimeUnixNano')); + } + + public function testEndNanosEdges(): void + { + $this->assertSame('1749988805118000000', OtlpPayload::endNanos(1749988805123, -5)); + $this->assertSame('0', OtlpPayload::endNanos(0, 0)); + $this->assertSame('0', OtlpPayload::endNanos(5, -5)); + $this->assertSame('0', OtlpPayload::endNanos(9_000_000_000_000_000, -9.0e15)); + $this->assertSame('1749988805123000000', OtlpPayload::endNanos(1749988805123, null)); + $this->assertSame('1749988805123000000', OtlpPayload::endNanos(1749988805123, -0.0)); + } + + public function testEndNanosFallsBackToFloatArithmeticBeyondTheIntRange(): void + { + $this->assertSame('9223373786843580416000000', OtlpPayload::endNanos(1749988805123, PHP_INT_MAX)); + $this->assertSame('9000000000000000000000', OtlpPayload::endNanos(0, 9.0e15)); + $this->assertSame('-9000000000000000000000', OtlpPayload::endNanos(0, -9.0e15)); + $this->assertSame('9223372036854775808000000', OtlpPayload::endNanos(0, 9223372036854775808.0)); + $this->assertSame('-9223372036854775808000000', OtlpPayload::endNanos(0, -9223372036854775808.0)); + $this->assertSame('4000001749988805123000000', OtlpPayload::endNanos(1749988805123, 4.0e18)); + $this->assertMatchesRegularExpression('/\A[0-9]+000000\z/', OtlpPayload::endNanos(1749988805123, 1.0e300)); + } + + public function testStatusCode(): void + { + $event = ExporterFixtures::event(); + $event->isError = true; + $this->assertSame(2, $this->span($event, 'status', 'code')); + + $event->isError = false; + $this->assertSame(1, $this->span($event, 'status', 'code')); + + $event->isError = null; + $this->assertSame(1, $this->span($event, 'status', 'code')); + } + + public function testDefaultsForMissingServerAndVersion(): void + { + $event = ExporterFixtures::event(); + $event->agentcatVersion = null; + $event->serverName = null; + $event->serverVersion = null; + + $this->assertSame('unknown', $this->resourceField($event, 'scopeSpans', 0, 'scope', 'version')); + $this->assertSame('mcp-server', $this->resourceField($event, 'resource', 'attributes', 0, 'value', 'stringValue')); + $this->assertSame('unknown', $this->resourceField($event, 'resource', 'attributes', 1, 'value', 'stringValue')); + $this->assertSame('agentcat', $this->resourceField($event, 'scopeSpans', 0, 'scope', 'name')); + } + + public function testAnEmptyEventTypeNamesTheSpanMcpEvent(): void + { + $event = ExporterFixtures::event(); + $event->eventType = ''; + + $this->assertSame('mcp.event', $this->span($event, 'name')); + $this->assertSame('mcp:tools/call', $this->span(ExporterFixtures::event(), 'name')); + $this->assertSame(2, $this->span(ExporterFixtures::event(), 'kind')); + } + + public function testTheRequestCarriesTheConfiguredUrlAndHeaders(): void + { + $request = OtlpPayload::of(new OtlpExporter('http://h', ['k' => 'v']))->request(ExporterFixtures::event()); + + $this->assertSame('http://h/v1/traces', $request->url); + $this->assertSame(['Content-Type' => 'application/json', 'k' => 'v'], $request->headers); + } + + private function span(Event $event, string|int ...$path): mixed + { + return JsonAssert::at(Json::decode(OtlpPayload::of(new OtlpExporter('http://h'))->request($event)->body), 'resourceSpans', 0, 'scopeSpans', 0, 'spans', 0, ...$path); + } + + private function resourceField(Event $event, string|int ...$path): mixed + { + return JsonAssert::at(Json::decode(OtlpPayload::of(new OtlpExporter('http://h'))->request($event)->body), 'resourceSpans', 0, ...$path); + } + + /** @return array */ + private function attributes(Event $event): array + { + $attributes = $this->span($event, 'attributes'); + $this->assertIsArray($attributes); + $map = []; + foreach ($attributes as $attribute) { + $key = JsonAssert::at($attribute, 'key'); + $this->assertIsString($key); + $map[$key] = JsonAssert::at($attribute, 'value', 'stringValue'); + } + + return $map; + } +} diff --git a/tests/Unit/Internal/Telemetry/OtlpExportTest.php b/tests/Unit/Internal/Telemetry/OtlpExportTest.php new file mode 100644 index 0000000..bb6bbf4 --- /dev/null +++ b/tests/Unit/Internal/Telemetry/OtlpExportTest.php @@ -0,0 +1,116 @@ +logger = new MemoryLogger(); + Log::setLogger($this->logger); + } + + #[\Override] + protected function tearDown(): void + { + Log::reset(); + } + + #[Group('ac-06-18')] + public function testASuccessfulExportPostsTheSpanAndLogsTheEventId(): void + { + $config = new OtlpExporter('http://otlp.test', ['X-Key' => 'k']); + $http = new RecordingHttpChannel(); + $event = ExporterFixtures::event(); + + $this->export($http, $event, $config); + + $expected = OtlpPayload::of($config)->request($event); + $this->assertCount(1, $http->requests); + $this->assertSame($expected->url, $http->requests[0]->url); + $this->assertSame($expected->headers, $http->requests[0]->headers); + $this->assertSame($expected->body, $http->requests[0]->body); + $this->assertSame([self::INIT, 'Successfully exported event to OTLP: ' . ExporterFixtures::EVENT], $this->logger->messages()); + } + + #[DataProvider('failures')] + #[Group('ac-06-23')] + public function testAFailedRequestRejectsWithTheTypeScriptText(HttpResponse $response, string $line): void + { + $this->export(new RecordingHttpChannel([$response]), ExporterFixtures::event()); + + $this->assertSame([self::INIT, $line], $this->logger->messages()); + } + + /** @return iterable */ + public static function failures(): iterable + { + yield 'a 404' => [new HttpResponse(404, 'Not Found', 'nf'), 'Telemetry export failed for otlp: OTLP export error: Error: OTLP export failed: 404 Not Found']; + yield 'an HTTP/2 404 has no reason phrase (statusText "")' => [new HttpResponse(404, '', 'nf'), 'Telemetry export failed for otlp: OTLP export error: Error: OTLP export failed: 404 ']; + yield 'a 3xx is not ok' => [new HttpResponse(301, 'Moved Permanently', ''), 'Telemetry export failed for otlp: OTLP export error: Error: OTLP export failed: 301 Moved Permanently']; + yield 'a network error (TS: the fetch rejection)' => [HttpResponse::failed('cURL error 7: Failed to connect'), 'Telemetry export failed for otlp: OTLP export error: cURL error 7: Failed to connect']; + } + + /** Spec conflict 1 (D15, B04): a truthy duration that is not an integer fails like BigInt() and sends nothing. */ + #[DataProvider('fractionalDurations')] + #[Group('ac-06-24')] + public function testAFractionalDurationSendsNothingAndRejectsWithTheRangeError(float $duration, string $number): void + { + $http = new RecordingHttpChannel(); + $event = ExporterFixtures::event(); + $event->duration = $duration; + + $this->export($http, $event); + + $this->assertSame([], $http->requests); + $this->assertSame([self::INIT, "Telemetry export failed for otlp: OTLP export error: RangeError: The number {$number} cannot be converted to a BigInt because it is not an integer"], $this->logger->messages()); + } + + /** @return iterable */ + public static function fractionalDurations(): iterable + { + yield '1.5' => [1.5, '1.5']; + yield 'infinity' => [INF, 'Infinity']; + } + + public function testAPayloadThatCannotBeEncodedSendsNothingAndRejectsWithThePhpError(): void + { + $http = new RecordingHttpChannel(); + $cycle = new \stdClass(); + $cycle->self = $cycle; + $event = ExporterFixtures::event(); + $event->properties = $cycle; + + $this->export($http, $event); + + $this->assertSame([], $http->requests); + $this->assertSame([self::INIT, 'Telemetry export failed for otlp: OTLP export error: UnexpectedValueException: Converting circular structure to JSON'], $this->logger->messages()); + } + + private function export(RecordingHttpChannel $http, Event $event, ?OtlpExporter $config = null): void + { + $manager = TelemetryManager::fromConfigs(['otlp' => $config ?? new OtlpExporter('http://otlp.test')], $http); + $manager->export($event); + $http->pump(); + } +} diff --git a/tests/Unit/Internal/Telemetry/PostHog/PostHogPayloadTest.php b/tests/Unit/Internal/Telemetry/PostHog/PostHogPayloadTest.php new file mode 100644 index 0000000..9fbc683 --- /dev/null +++ b/tests/Unit/Internal/Telemetry/PostHog/PostHogPayloadTest.php @@ -0,0 +1,481 @@ +assertSame($name, PostHogPayload::eventName($type)); + } + + /** @return iterable */ + public static function eventNames(): iterable + { + yield 'tools/call' => ['mcp:tools/call', 'mcp_tool_call']; + yield 'tools/list' => ['mcp:tools/list', 'mcp_tools_list']; + yield 'initialize' => ['mcp:initialize', 'mcp_initialize']; + yield 'resources/read' => ['mcp:resources/read', 'mcp_resource_read']; + yield 'resources/list' => ['mcp:resources/list', 'mcp_resources_list']; + yield 'prompts/get' => ['mcp:prompts/get', 'mcp_prompt_get']; + yield 'prompts/list' => ['mcp:prompts/list', 'mcp_prompts_list']; + yield 'ping' => ['mcp:ping', 'mcp_ping']; + yield 'templates' => ['mcp:resources/templates/list', 'mcp_resources_templates_list']; + yield 'custom mcp type' => ['mcp:custom/type', 'mcp_custom_type']; + yield 'custom agentcat type' => ['agentcat:custom', 'mcp_agentcat:custom']; + yield 'identify' => ['agentcat:identify', 'mcp_agentcat:identify']; + yield 'empty' => ['', 'mcp_']; + yield 'only the first prefix goes' => ['mcp:mcp:x', 'mcp_mcp:x']; + yield 'prefix in the middle stays' => ['x/mcp:y', 'mcp_x_mcp:y']; + } + + #[Group('ac-06-44')] + public function testTagsReplaceAiSpanDefaults(): void + { + $event = ExporterFixtures::event(); + $event->tags = ['$ai_trace_id' => 'x', '$ai_span_name' => 'y']; + $span = $this->properties($event, 1, ai: true); + + $this->assertSame('x', JsonAssert::at($span, '$ai_trace_id')); + $this->assertSame('y', JsonAssert::at($span, '$ai_span_name')); + $this->assertSame(['$ai_trace_id', '$ai_span_id', '$ai_span_name'], array_slice(array_keys(get_object_vars($span)), 0, 3)); + } + + #[Group('ac-06-44')] + public function testPropertiesReplaceTagsAndEverythingElse(): void + { + $event = ExporterFixtures::event(); + $event->tags = ['a' => 'tag', 'source' => 'tag']; + $event->properties = Json::decode('{"a":"prop","server_name":"mine"}'); + + $capture = $this->properties($event, 0); + $span = $this->properties($event, 1, ai: true); + + foreach ([$capture, $span] as $properties) { + $this->assertSame('prop', JsonAssert::at($properties, 'a')); + $this->assertSame('tag', JsonAssert::at($properties, 'source')); + $this->assertSame('mine', JsonAssert::at($properties, 'server_name')); + } + $this->assertSame('source', array_key_first(get_object_vars($capture))); + } + + #[Group('ac-06-44')] + public function testIntegerKeysAndAnObjectOfPropertiesAreSpread(): void + { + $event = new Event(id: 'evt_1', eventType: 'mcp:ping', tags: [7 => 'seven'], properties: (object) ['p' => 1]); + $properties = $this->properties($event, 0); + + $this->assertSame('seven', JsonAssert::at($properties, '7')); + $this->assertSame(1, JsonAssert::at($properties, 'p')); + } + + #[Group('ac-06-44')] + public function testAKeyStartingWithNulIsLeftOutAndTheBatchIsStillBuilt(): void + { + $event = new Event(id: 'evt_1', eventType: 'mcp:tools/call', tags: ["\0bad" => 'x', 'ok' => 'y'], properties: (object) ["\0worse" => 1, 'p' => 2]); + + foreach ([0, 1] as $index) { + $this->assertSame(['source', 'ok', 'p'], array_slice(array_keys(get_object_vars($this->properties($event, $index, ai: true))), $index === 0 ? 0 : 4, 3), "item {$index}"); + } + } + + #[Group('ac-06-44')] + public function testScalarOrFalsyPropertiesAndTagsAreNotSpread(): void + { + foreach (['text', 5, true, [], 0, ''] as $properties) { + $event = new Event(id: 'evt_1', eventType: 'mcp:ping', tags: [], properties: $properties); + + $this->assertSame(['source'], array_keys(get_object_vars($this->properties($event, 0)))); + } + } + + #[Group('ac-06-45')] + public function testActorDataReplacesTheActorNameInSet(): void + { + $event = ExporterFixtures::event(); + $event->identifyActorData = Json::decode('{"name":"Bob"}'); + $event->identifyActorName = 'Alice'; + $set = JsonAssert::at($this->properties($event, 0), '$set'); + + $this->assertSame(['name' => 'Bob'], get_object_vars($set instanceof \stdClass ? $set : new \stdClass())); + } + + #[Group('ac-06-45')] + public function testTheActorNameAndDataFillSet(): void + { + $set = JsonAssert::at($this->properties(ExporterFixtures::event(), 0), '$set'); + + $this->assertSame('{"name":"Alice","email":"alice@example.com"}', ExportJson::encode($set)); + } + + #[Group('ac-06-45')] + #[DataProvider('noIdentity')] + public function testNoIdentityMeansNoSet(?string $name, mixed $data): void + { + $event = new Event(id: 'evt_1', eventType: 'mcp:ping', identifyActorName: $name, identifyActorData: $data); + + $this->assertFalse(property_exists($this->properties($event, 0), '$set')); + } + + /** @return iterable */ + public static function noIdentity(): iterable + { + yield 'nothing' => [null, null]; + yield 'empty data' => [null, []]; + yield 'empty name and data' => ['', []]; + yield 'empty object' => [null, new \stdClass()]; + yield 'scalar data' => [null, 'text']; + yield 'false data' => [null, false]; + } + + #[Group('ac-06-45')] + public function testTheActorNameAloneAndDataAloneFillSet(): void + { + $name = new Event(id: 'evt_1', eventType: 'mcp:ping', identifyActorName: 'Alice'); + $data = new Event(id: 'evt_1', eventType: 'mcp:ping', identifyActorData: ['plan' => 'pro']); + + $this->assertSame('{"name":"Alice"}', ExportJson::encode(JsonAssert::at($this->properties($name, 0), '$set'))); + $this->assertSame('{"plan":"pro"}', ExportJson::encode(JsonAssert::at($this->properties($data, 0), '$set'))); + } + + public function testTheDistinctIdIsTheActorThenTheSessionThenAnonymous(): void + { + $event = ExporterFixtures::event(); + $this->assertSame('user_abc', $this->distinct($event)); + + $event->identifyActorGivenId = null; + $this->assertSame(ExporterFixtures::SESSION, $this->distinct($event)); + + $event->identifyActorGivenId = ''; + $this->assertSame($event->sessionId, $this->distinct($event)); + + $event->sessionId = ''; + $this->assertSame('anonymous', $this->distinct($event)); + } + + public function testEveryItemOfABatchHasTheSameDistinctIdTimestampAndType(): void + { + $event = ExporterFixtures::errorEvent(); + $items = $this->items($event, ai: true); + + $this->assertCount(3, $items); + foreach ($items as $item) { + $this->assertSame('user_abc', JsonAssert::at($item, 'distinct_id')); + $this->assertSame('2025-06-15T12:00:05.123Z', JsonAssert::at($item, 'timestamp')); + $this->assertSame('capture', JsonAssert::at($item, 'type')); + $this->assertSame(['event', 'distinct_id', 'properties', 'timestamp', 'type'], array_keys(get_object_vars($item))); + } + } + + public function testASessionlessErrorWithAiTracingCarriesNoSessionIds(): void + { + $event = ExporterFixtures::errorEvent(); + $event->sessionId = ''; + $items = $this->items($event, ai: true); + + $this->assertCount(3, $items); + $this->assertSame(['mcp_tool_call', '$exception', '$ai_span'], array_map(static fn(\stdClass $item): mixed => $item->event, $items)); + foreach (array_keys($items) as $index) { + $this->assertFalse(property_exists($this->properties($event, $index, ai: true), '$session_id')); + } + $span = $this->properties($event, 2, ai: true); + $this->assertFalse(property_exists($span, '$ai_session_id')); + $this->assertSame(UuidV7::fromPrefixedId($event->id), JsonAssert::at($span, '$ai_trace_id')); + } + + public function testASessionHasATraceAndSessionIds(): void + { + $event = ExporterFixtures::errorEvent(); + $uuid = UuidV7::fromPrefixedId(ExporterFixtures::SESSION); + $span = $this->properties($event, 2, ai: true); + + $this->assertSame($uuid, JsonAssert::at($span, '$ai_trace_id')); + $this->assertSame($uuid, JsonAssert::at($span, '$session_id')); + $this->assertSame('agentcat_' . ExporterFixtures::SESSION, JsonAssert::at($span, '$ai_session_id')); + $this->assertSame(UuidV7::fromPrefixedId(ExporterFixtures::EVENT), JsonAssert::at($span, '$ai_span_id')); + $this->assertSame($uuid, JsonAssert::at($this->properties($event, 0, ai: true), '$session_id')); + $this->assertSame($uuid, JsonAssert::at($this->properties($event, 1, ai: true), '$session_id')); + } + + public function testAnExceptionNeedsAnErrorFlagAndAnError(): void + { + foreach ([[true, null], [true, ''], [false, 'boom'], [null, 'boom'], [false, null], [true, 0], [true, false]] as [$flag, $error]) { + $event = ExporterFixtures::event(); + $event->isError = $flag; + $event->error = $error; + + $this->assertCount(1, $this->items($event), var_export([$flag, $error], true)); + } + $event = ExporterFixtures::event(); + $event->isError = true; + $event->error = 'boom'; + $this->assertCount(2, $this->items($event)); + } + + public function testAStringErrorHasNoExceptionDetails(): void + { + $event = ExporterFixtures::event(); + $event->isError = true; + $event->error = 'boom'; + $exception = $this->properties($event, 1); + + $this->assertSame(['$exception_source', '$session_id', 'resource_name', 'tool_name', 'server_name', 'server_version', 'client_name', 'client_version'], array_keys(get_object_vars($exception))); + $this->assertSame('backend', JsonAssert::at($exception, '$exception_source')); + } + + public function testAnObjectErrorGivesMessageTypeAndStackWhenTruthy(): void + { + $event = ExporterFixtures::errorEvent(); + $exception = $this->properties($event, 1); + + $this->assertSame( + ['$exception_source', '$session_id', '$exception_message', '$exception_type', '$exception_stacktrace', 'resource_name', 'tool_name', 'server_name', 'server_version', 'client_name', 'client_version'], + array_keys(get_object_vars($exception)), + ); + $this->assertSame('boom', JsonAssert::at($exception, '$exception_message')); + $this->assertSame('TypeError', JsonAssert::at($exception, '$exception_type')); + $this->assertSame("TypeError: boom\n at x (/a.js:1:1)", JsonAssert::at($exception, '$exception_stacktrace')); + + $event->error = Json::decode('{"message":"","type":0,"stack":null}'); + $this->assertSame(['$exception_source', '$session_id'], array_slice(array_keys(get_object_vars($this->properties($event, 1))), 0, 2)); + $this->assertFalse(property_exists($this->properties($event, 1), '$exception_message')); + $this->assertFalse(property_exists($this->properties($event, 1), '$exception_type')); + $this->assertFalse(property_exists($this->properties($event, 1), '$exception_stacktrace')); + + $event->error = ['message' => 'from array']; + $this->assertSame('from array', JsonAssert::at($this->properties($event, 1), '$exception_message')); + } + + public function testTheExceptionOfANonToolCallHasNoToolName(): void + { + $event = ExporterFixtures::errorEvent(); + $event->eventType = 'mcp:resources/read'; + $event->sessionId = ''; + $event->serverName = ''; + $event->serverVersion = null; + $event->clientName = ''; + $event->clientVersion = null; + $event->resourceName = ''; + $exception = $this->properties($event, 1); + + $this->assertSame(['$exception_source', '$exception_message', '$exception_type', '$exception_stacktrace'], array_keys(get_object_vars($exception))); + + $event->resourceName = 'file:///a'; + $this->assertSame('file:///a', JsonAssert::at($this->properties($event, 1), 'resource_name')); + $this->assertFalse(property_exists($this->properties($event, 1), 'tool_name')); + } + + public function testNoSpanWithoutAiTracingOrForOtherEventTypes(): void + { + $this->assertCount(1, $this->items(ExporterFixtures::event())); + $this->assertCount(2, $this->items(ExporterFixtures::event(), ai: true)); + + $event = ExporterFixtures::event(); + $event->eventType = 'mcp:tools/list'; + $this->assertCount(1, $this->items($event, ai: true)); + } + + public function testToolNameOnlyForToolCalls(): void + { + $event = ExporterFixtures::event(); + $properties = $this->properties($event, 0); + $this->assertSame('get_weather', JsonAssert::at($properties, 'tool_name')); + $this->assertSame(['resource_name', 'tool_name'], array_slice(array_keys(get_object_vars($properties)), 2, 2)); + + $event->eventType = 'mcp:tools/list'; + $properties = $this->properties($event, 0); + $this->assertSame('get_weather', JsonAssert::at($properties, 'resource_name')); + $this->assertFalse(property_exists($properties, 'tool_name')); + + $event->resourceName = ''; + $properties = $this->properties($event, 0); + $this->assertFalse(property_exists($properties, 'resource_name')); + $this->assertFalse(property_exists($properties, 'tool_name')); + } + + public function testBlankCaptureFieldsAreLeftOut(): void + { + $event = new Event(id: 'evt_1', projectId: '', eventType: 'mcp:ping', serverName: '', serverVersion: '', clientName: '', clientVersion: '', userIntent: ''); + + $this->assertSame(['source'], array_keys(get_object_vars($this->properties($event, 0)))); + } + + public function testTheCaptureKeepsItsOrderAndZeroValues(): void + { + $event = ExporterFixtures::event(); + $event->duration = 0; + $event->isError = false; + $event->parameters = 0; + $event->response = ''; + $properties = $this->properties($event, 0); + + $this->assertSame( + ['source', '$session_id', 'resource_name', 'tool_name', 'duration_ms', 'server_name', 'server_version', 'client_name', 'client_version', 'project_id', 'user_intent', 'is_error', 'parameters', 'response', '$set', 'env', 'Bad Key:x', 'device'], + array_keys(get_object_vars($properties)), + ); + $this->assertSame(0, JsonAssert::at($properties, 'duration_ms')); + $this->assertFalse(JsonAssert::at($properties, 'is_error')); + $this->assertSame(0, JsonAssert::at($properties, 'parameters')); + $this->assertSame('', JsonAssert::at($properties, 'response')); + } + + public function testMissingValuesAreLeftOutOfTheCapture(): void + { + $event = ExporterFixtures::event(); + $event->duration = null; + $event->isError = null; + $event->parameters = null; + $event->response = null; + $properties = $this->properties($event, 0); + + foreach (['duration_ms', 'is_error', 'parameters', 'response'] as $key) { + $this->assertFalse(property_exists($properties, $key), $key); + } + } + + public function testParametersAndResponseStringsPassThroughAsStrings(): void + { + $event = ExporterFixtures::event(); + $event->parameters = '{"city":"London"}'; + $event->response = 'text'; + $capture = $this->properties($event, 0, ai: true); + $span = $this->properties($event, 1, ai: true); + + $this->assertSame('{"city":"London"}', JsonAssert::at($capture, 'parameters')); + $this->assertSame('text', JsonAssert::at($capture, 'response')); + $this->assertSame('{"city":"London"}', JsonAssert::at($span, '$ai_input_state')); + $this->assertSame('text', JsonAssert::at($span, '$ai_output_state')); + } + + public function testTheAiLatencyIsSeconds(): void + { + $event = ExporterFixtures::event(); + $event->duration = 1000; + $this->assertStringContainsString('"$ai_latency":1,', ExportJson::encode($this->properties($event, 1, ai: true))); + $event->duration = 1000.0; + $this->assertStringContainsString('"$ai_latency":1,', ExportJson::encode($this->properties($event, 1, ai: true))); + $event->duration = 150; + $this->assertStringContainsString('"$ai_latency":0.15,', ExportJson::encode($this->properties($event, 1, ai: true))); + $event->duration = 0; + $this->assertStringContainsString('"$ai_latency":0,', ExportJson::encode($this->properties($event, 1, ai: true))); + $event->duration = null; + $this->assertFalse(property_exists($this->properties($event, 1, ai: true), '$ai_latency')); + } + + public function testTheSpanFollowsTheEvent(): void + { + $event = ExporterFixtures::event(); + $span = $this->properties($event, 1, ai: true); + + $this->assertSame( + ['$ai_trace_id', '$ai_span_id', '$ai_span_name', '$ai_is_error', 'source', '$ai_session_id', '$session_id', '$ai_latency', '$ai_input_state', '$ai_output_state', 'server_name', 'client_name', 'env', 'Bad Key:x', 'device'], + array_keys(get_object_vars($span)), + ); + $this->assertSame('get_weather', JsonAssert::at($span, '$ai_span_name')); + $this->assertFalse(JsonAssert::at($span, '$ai_is_error')); + $this->assertSame('agentcat', JsonAssert::at($span, 'source')); + + $event->resourceName = ''; + $event->isError = null; + $event->parameters = null; + $event->response = null; + $event->serverName = ''; + $event->clientName = null; + $span = $this->properties($event, 1, ai: true); + $this->assertSame('unknown_tool', JsonAssert::at($span, '$ai_span_name')); + $this->assertFalse(JsonAssert::at($span, '$ai_is_error')); + foreach (['$ai_input_state', '$ai_output_state', 'server_name', 'client_name', '$ai_error'] as $key) { + $this->assertFalse(property_exists($span, $key), $key); + } + } + + public function testTheSpanCarriesTheErrorOfAFailedCallOnly(): void + { + $failed = ExporterFixtures::errorEvent(); + $span = $this->properties($failed, 2, ai: true); + $this->assertTrue(JsonAssert::at($span, '$ai_is_error')); + $this->assertSame('boom', JsonAssert::at($span, '$ai_error', 'message')); + + $failed->error = 'text'; + $this->assertSame('text', JsonAssert::at($this->properties($failed, 2, ai: true), '$ai_error')); + + $flagOnly = ExporterFixtures::event(); + $flagOnly->isError = true; + $this->assertTrue(JsonAssert::at($this->properties($flagOnly, 1, ai: true), '$ai_is_error')); + $this->assertFalse(property_exists($this->properties($flagOnly, 1, ai: true), '$ai_error')); + + $errorOnly = ExporterFixtures::event(); + $errorOnly->error = 'text'; + $this->assertFalse(property_exists($this->properties($errorOnly, 1, ai: true), '$ai_error')); + } + + #[Group('ac-06-42')] + public function testHostsBuildTheBatchUrl(): void + { + $this->assertSame('https://us.i.posthog.com/batch', PostHogPayload::of(new PostHogExporter('k'))->batchUrl); + $this->assertSame('https://us.i.posthog.com/batch', PostHogPayload::of(new PostHogExporter('k', ''))->batchUrl); + $this->assertSame('https://eu.i.posthog.com/batch', PostHogPayload::of(new PostHogExporter('k', 'https://eu.i.posthog.com/'))->batchUrl); + $this->assertSame('https://eu.i.posthog.com/batch', PostHogPayload::of(new PostHogExporter('k', 'https://eu.i.posthog.com'))->batchUrl); + $this->assertSame('https://h//batch', PostHogPayload::of(new PostHogExporter('k', 'https://h//'))->batchUrl); + $this->assertSame('http://127.0.0.1:9/ph/batch', PostHogPayload::of(new PostHogExporter('k', 'http://127.0.0.1:9/ph/'))->batchUrl); + $this->assertSame('0/batch', PostHogPayload::of(new PostHogExporter('k', '0'))->batchUrl); + } + + public function testTheRequestPostsTheKeyAndTheBatchAsJson(): void + { + $payload = PostHogPayload::of(new PostHogExporter('phc_x', 'https://h')); + $batch = $payload->batch(ExporterFixtures::event()); + $request = $payload->request($batch); + + $this->assertSame('https://h/batch', $request->url); + $this->assertSame(['Content-Type' => 'application/json'], $request->headers); + $this->assertSame(ExportJson::encode(['api_key' => 'phc_x', 'batch' => $batch]), $request->body); + $this->assertStringStartsWith('{"api_key":"phc_x","batch":[{"event":"mcp_tool_call"', $request->body); + } + + /** @return list<\stdClass> */ + private function items(Event $event, bool $ai = false): array + { + $items = Json::decode(ExportJson::encode(PostHogPayload::of(new PostHogExporter('phc_x', enableAITracing: $ai))->batch($event))); + $this->assertIsArray($items); + $list = []; + foreach ($items as $item) { + $this->assertInstanceOf(\stdClass::class, $item); + $list[] = $item; + } + + return $list; + } + + private function properties(Event $event, int $index, bool $ai = false): \stdClass + { + $properties = JsonAssert::at($this->items($event, $ai)[$index] ?? null, 'properties'); + $this->assertInstanceOf(\stdClass::class, $properties); + + return $properties; + } + + private function distinct(Event $event): string + { + $id = JsonAssert::at($this->items($event)[0] ?? null, 'distinct_id'); + $this->assertIsString($id); + + return $id; + } +} diff --git a/tests/Unit/Internal/Telemetry/PostHogExportTest.php b/tests/Unit/Internal/Telemetry/PostHogExportTest.php new file mode 100644 index 0000000..5f7a5a4 --- /dev/null +++ b/tests/Unit/Internal/Telemetry/PostHogExportTest.php @@ -0,0 +1,122 @@ +logger = new MemoryLogger(); + Log::setLogger($this->logger); + } + + #[\Override] + protected function tearDown(): void + { + Log::reset(); + } + + public function testTheConstructorLogsTheEndpoint(): void + { + new PostHogExport(PostHogPayload::of(new PostHogExporter('phc_x', 'https://eu.i.posthog.com/')), new RecordingHttpChannel()); + + $this->assertSame(['PostHogExporter: Initialized with endpoint https://eu.i.posthog.com/batch'], $this->logger->messages()); + } + + #[Group('ac-06-40')] + public function testASuccessfulExportPostsTheBatchAndLogsTheEventId(): void + { + $http = new RecordingHttpChannel(); + $event = ExporterFixtures::errorEvent(); + + $this->export($http, $event); + + $payload = PostHogPayload::of(new PostHogExporter('phc_x')); + $expected = $payload->request($payload->batch($event)); + $this->assertCount(1, $http->requests); + $this->assertSame($expected->url, $http->requests[0]->url); + $this->assertSame($expected->headers, $http->requests[0]->headers); + $this->assertSame($expected->body, $http->requests[0]->body); + $this->assertSame([self::INIT, self::SENDING_TWO, 'PostHog export success - Event: ' . ExporterFixtures::EVENT], $this->logger->messages()); + } + + #[DataProvider('failures')] + public function testAFailedRequestLogsItsLine(HttpResponse $response, string $line): void + { + $this->export(new RecordingHttpChannel([$response]), ExporterFixtures::errorEvent()); + + $this->assertSame([self::INIT, self::SENDING_TWO, $line], $this->logger->messages()); + } + + /** @return iterable */ + public static function failures(): iterable + { + yield 'a 400' => [new HttpResponse(400, 'Bad Request', 'bad'), 'PostHog export failed - Status: 400, Body: bad']; + yield 'a network error' => [HttpResponse::failed('cURL error 7: x'), 'PostHog export error: cURL error 7: x']; + } + + /** 06 AC-16: the exporter sends `parameters` and `response` as the event holds them (after the pipeline's redaction). */ + #[Group('ac-06-16')] + public function testParametersAndResponseAreSentAsTheEventHoldsThem(): void + { + $http = new RecordingHttpChannel(); + $event = ExporterFixtures::event(); + $event->parameters = Json::decode('{"city":"[REDACTED]"}'); + $event->response = Json::decode('{"content":[{"type":"text","text":"[REDACTED]"}]}'); + + $this->export($http, $event); + + $this->assertStringContainsString('"parameters":{"city":"[REDACTED]"},"response":{"content":[{"type":"text","text":"[REDACTED]"}]}', $http->requests[0]->body ?? ''); + $this->assertStringNotContainsString('London', $http->requests[0]->body ?? ''); + } + + public function testABatchThatCannotBeEncodedSendsNothingAndLogsTheError(): void + { + $http = new RecordingHttpChannel(); + $cycle = new \stdClass(); + $cycle->self = $cycle; + $event = ExporterFixtures::event(); + $event->parameters = $cycle; + + $this->export($http, $event); + + $this->assertSame([], $http->requests); + $this->assertSame([ + self::INIT, + 'PostHogExporter: Sending 1 event(s) for ' . ExporterFixtures::EVENT, + 'PostHog export error: UnexpectedValueException: Converting circular structure to JSON', + ], $this->logger->messages(), 'TS logs the count before JSON.stringify'); + } + + private function export(RecordingHttpChannel $http, Event $event): void + { + $exporter = new PostHogExport(PostHogPayload::of(new PostHogExporter('phc_x')), $http); + $exporter->export($event, static function (string $message): never { + self::fail('PostHog logs its own failures and never rejects: ' . $message); + }); + $http->pump(); + } +} diff --git a/tests/Unit/Internal/Telemetry/Sentry/SentryDsnTest.php b/tests/Unit/Internal/Telemetry/Sentry/SentryDsnTest.php new file mode 100644 index 0000000..347ba30 --- /dev/null +++ b/tests/Unit/Internal/Telemetry/Sentry/SentryDsnTest.php @@ -0,0 +1,103 @@ +assertSame('https://o1.ingest.sentry.io/api/42/envelope/', $dsn->endpoint); + $this->assertSame('abc123', $dsn->publicKey); + } + + #[Group('ac-06-31')] + public function testTheAuthHeaderNamesTheClientAndTheKey(): void + { + $payload = SentryPayload::of(new SentryExporter('https://abc123@o1.ingest.sentry.io/42')); + + $this->assertSame( + ['X-Sentry-Auth' => 'Sentry sentry_version=7, sentry_client=agentcat/1.0.0, sentry_key=abc123', 'Content-Type' => 'application/x-sentry-envelope'], + $payload->headers(), + ); + $this->assertSame('https://o1.ingest.sentry.io/api/42/envelope/', $payload->endpoint); + } + + #[Group('ac-06-32')] + #[DataProvider('validDsns')] + public function testPortAndPathAreKept(string $dsn, string $endpoint): void + { + $this->assertSame($endpoint, SentryDsn::parse($dsn)->endpoint); + } + + /** @return iterable */ + public static function validDsns(): iterable + { + yield 'port and path' => ['https://abc123@sentry.example.com:9000/sub/path/42', 'https://sentry.example.com:9000/sub/path/api/42/envelope/']; + yield 'http stays http' => ['http://abc123@localhost/7', 'http://localhost/api/7/envelope/']; + yield 'port only' => ['http://abc123@localhost:8080/7', 'http://localhost:8080/api/7/envelope/']; + yield 'path only' => ['https://abc123@h.io/team/7', 'https://h.io/team/api/7/envelope/']; + yield 'hyphen and digits in the host' => ['https://0af@my-host.example.com/1', 'https://my-host.example.com/api/1/envelope/']; + yield 'empty path segment' => ['https://abc@h//5', 'https://h//api/5/envelope/']; + } + + #[Group('ac-06-32')] + public function testThePublicKeyOfAnotherDsnIsUsed(): void + { + $this->assertSame('0123456789abcdef', SentryDsn::parse('https://0123456789abcdef@h/1')->publicKey); + } + + /** + * A3: the message names the DSN with its user-info masked: the public key, and the secret of a legacy + * `https://public:secret@host/1` DSN, never reach a log line or diagnostics. Scheme, host, port and path stay. + * A deliberate divergence from TypeScript, which prints the whole DSN. + */ + #[Group('ac-06-33')] + #[DataProvider('invalidDsns')] + public function testAnInvalidDsnThrowsWithTheMaskedDsnInTheMessage(string $dsn, string $logged): void + { + $this->assertSame('Invalid Sentry DSN: ' . $logged, Thrown::by(\InvalidArgumentException::class, static fn(): SentryDsn => SentryDsn::parse($dsn))->getMessage()); + $this->assertSame('Invalid Sentry DSN: ' . $logged, Thrown::by(\InvalidArgumentException::class, static fn(): SentryPayload => SentryPayload::of(new SentryExporter($dsn)))->getMessage()); + } + + /** @return iterable the DSN, then how the message names it */ + public static function invalidDsns(): iterable + { + yield 'upper-case key' => ['https://ABC@o1.ingest.sentry.io/42', 'https://***@o1.ingest.sentry.io/42']; + yield 'secret' => ['https://abc:secret@o1.ingest.sentry.io/42', 'https://***@o1.ingest.sentry.io/42']; + yield 'an @ in the secret' => ['https://abc:se@cret@o1.ingest.sentry.io/42', 'https://***@o1.ingest.sentry.io/42']; + yield 'non-numeric project' => ['https://abc@o1.ingest.sentry.io/proj', 'https://***@o1.ingest.sentry.io/proj']; + yield 'scheme' => ['ftp://abc@h/1', 'ftp://***@h/1']; + yield 'no scheme' => ['abc@o1.ingest.sentry.io/42', '***@o1.ingest.sentry.io/42']; + yield 'trailing newline' => ["https://abc@h/42\n", "https://***@h/42\n"]; + yield 'leading text' => [' https://abc@h/42', ' https://***@h/42']; + yield 'no key' => ['https://h/42', 'https://h/42']; + yield 'no project' => ['https://abc@h/', 'https://***@h/']; + yield 'empty key: nothing to mask' => ['https://@h/42', 'https://@h/42']; + yield 'non-hex key' => ['https://xyz@h/42', 'https://***@h/42']; + yield 'port without digits' => ['https://abc@h:/42', 'https://***@h:/42']; + yield 'letters in the port' => ['https://abc@h:80a/42', 'https://***@h:80a/42']; + yield 'space in the host' => ['https://abc@h h/42', 'https://***@h h/42']; + yield 'carriage return in the path' => ["https://abc@h/a\rb/42", "https://***@h/a\rb/42"]; + yield 'line separator in the path' => ["https://abc@h/a\u{2028}b/42", "https://***@h/a\u{2028}b/42"]; + yield 'paragraph separator in the path' => ["https://abc@h/a\u{2029}b/42", "https://***@h/a\u{2029}b/42"]; + yield 'trailing project text' => ['https://abc@h/42x', 'https://***@h/42x']; + yield 'non-ASCII letter in the host' => ['https://abc@é.example.com/42', 'https://***@é.example.com/42']; + yield 'non-ASCII digits in the project' => ['https://abc@o1.sentry.io/٤٢', 'https://***@o1.sentry.io/٤٢']; + yield 'non-ASCII digits in the port' => ['https://abc@h:٨٠/42', 'https://***@h:٨٠/42']; + yield 'a :// after the @' => ['abc@h/x://y', '***@h/x://y']; + yield 'empty' => ['', '']; + } +} diff --git a/tests/Unit/Internal/Telemetry/Sentry/SentryPayloadTest.php b/tests/Unit/Internal/Telemetry/Sentry/SentryPayloadTest.php new file mode 100644 index 0000000..2988934 --- /dev/null +++ b/tests/Unit/Internal/Telemetry/Sentry/SentryPayloadTest.php @@ -0,0 +1,515 @@ +payload()->logEnvelope(ExporterFixtures::event(), '2026-10-17T00:00:00.000Z'); + $lines = explode("\n", $envelope); + + $this->assertCount(4, $lines); + $this->assertSame('', $lines[3]); + $this->assertSame('{"event_id":"38fbea6b36e16f3738fbea6b36e16f37","sent_at":"2026-10-17T00:00:00.000Z"}', $lines[0]); + $this->assertSame('{"type":"log","item_count":1,"content_type":"application/vnd.sentry.items.log+json"}', $lines[1]); + $this->assertStringStartsWith('{"items":[{"timestamp":1749988805.123,', $lines[2]); + $this->assertStringNotContainsString('"length"', $envelope); + } + + #[Group('ac-06-34')] + public function testTheTransactionEnvelopeHasNoTrailingNewlineAndTheTransactionItemHeader(): void + { + $payload = $this->payload(tracing: true); + $transaction = $payload->transaction(ExporterFixtures::event()); + $envelope = $payload->transactionEnvelope($transaction, 'SENT'); + $lines = explode("\n", $envelope); + + $this->assertCount(3, $lines); + $this->assertSame('{"event_id":"' . $transaction->eventId . '","sent_at":"SENT"}', $lines[0]); + $this->assertSame('{"type":"transaction"}', $lines[1]); + $this->assertSame(ExportJson::encode($transaction->body), $lines[2]); + $this->assertStringNotContainsString('"length"', $envelope); + } + + #[Group('ac-06-34')] + public function testTheErrorEnvelopeHasNoTrailingNewlineAndTheEventItemHeader(): void + { + $payload = $this->payload(); + $error = $payload->errorEvent(ExporterFixtures::errorEvent(), null); + $envelope = $payload->errorEnvelope($error, 'SENT'); + $lines = explode("\n", $envelope); + + $this->assertCount(3, $lines); + $this->assertSame('{"event_id":"' . $error['eventId'] . '","sent_at":"SENT"}', $lines[0]); + $this->assertSame('{"type":"event","content_type":"application/json"}', $lines[1]); + $this->assertSame(ExportJson::encode($error['body']), $lines[2]); + $this->assertStringNotContainsString('"length"', $envelope); + } + + #[Group('ac-06-31')] + public function testTracingFollowsTheConfiguration(): void + { + $this->assertTrue($this->payload(tracing: true)->enableTracing); + $this->assertFalse($this->payload()->enableTracing); + } + + public function testTheTransactionKeepsItsParts(): void + { + $event = ExporterFixtures::event(); + $transaction = $this->payload(tracing: true)->transaction($event); + + $this->assertSame(TraceContext::traceId(ExporterFixtures::SESSION), $transaction->traceId); + $this->assertSame(TraceContext::spanId(ExporterFixtures::EVENT), $transaction->spanId); + $this->assertSame('mcp:tools/call', $transaction->op); + $this->assertEqualsWithDelta(1749988805.123, $transaction->timestamp, PHP_FLOAT_EPSILON); + $this->assertSame('mcp:tools/call - get_weather', $transaction->name); + $this->assertSame($transaction->eventId, JsonAssert::at($this->tree($transaction->body), 'event_id')); + $this->assertSame('transaction', JsonAssert::at($this->tree($transaction->body), 'type')); + $this->assertSame($transaction->name, JsonAssert::at($this->tree($transaction->body), 'transaction')); + } + + public function testTheTransactionEventIdHasARandomSecondHalf(): void + { + $payload = $this->payload(tracing: true); + $first = $payload->transaction(ExporterFixtures::event()); + $second = $payload->transaction(ExporterFixtures::event()); + + $this->assertMatchesRegularExpression('/\A38fbea6b36e16f37[0-9a-f]{16}\z/', $first->eventId); + $this->assertNotSame($first->eventId, $second->eventId); + } + + public function testTheTransactionSpansTheDuration(): void + { + $tree = $this->tree($this->payload(tracing: true)->transaction(ExporterFixtures::event())->body); + + $this->assertEqualsWithDelta(1749988805.123, JsonAssert::at($tree, 'timestamp'), PHP_FLOAT_EPSILON); + $this->assertEqualsWithDelta(1749988804.9729998, JsonAssert::at($tree, 'start_timestamp'), PHP_FLOAT_EPSILON); + } + + #[Group('ac-06-36')] + public function testAZeroOrMissingDurationStartsWhereItEnds(): void + { + foreach ([0, null, 0.0, NAN] as $duration) { + $event = ExporterFixtures::event(); + $event->duration = $duration; + $tree = $this->tree($this->payload(tracing: true)->transaction($event)->body); + + $this->assertSame(JsonAssert::at($tree, 'timestamp'), JsonAssert::at($tree, 'start_timestamp')); + } + } + + #[Group('ac-06-36')] + public function testAnEventWithAWholeSecondTimestampKeepsAnIntegerTimestamp(): void + { + $event = ExporterFixtures::event(); + $event->timestamp = new \DateTimeImmutable('2025-06-15T12:00:05.000Z'); + $event->duration = 2000; + $body = ExportJson::encode($this->payload(tracing: true)->transaction($event)->body); + + $this->assertStringContainsString('"timestamp":1749988805,"start_timestamp":1749988803,', $body); + } + + #[Group('ac-06-36')] + public function testATimestampBeforeTheEpochIsNegative(): void + { + $event = ExporterFixtures::event(); + $event->timestamp = new \DateTimeImmutable('1969-12-31T23:59:58.500Z'); + $event->duration = 0; + + $this->assertSame(-1.5, $this->payload(tracing: true)->transaction($event)->timestamp); + } + + #[Group('ac-06-36')] + public function testTheTransactionStatusAndContexts(): void + { + $event = ExporterFixtures::event(); + $tree = $this->tree($this->payload(tracing: true)->transaction($event)->body); + + $this->assertSame('ok', JsonAssert::at($tree, 'contexts', 'trace', 'status')); + $this->assertSame('mcp:tools/call', JsonAssert::at($tree, 'contexts', 'trace', 'op')); + $this->assertSame(['trace', 'agentcat'], array_keys($this->vars(JsonAssert::at($tree, 'contexts')))); + $this->assertSame('desktop', JsonAssert::at($tree, 'contexts', 'agentcat', 'device')); + + $event->isError = true; + $tree = $this->tree($this->payload(tracing: true)->transaction($event)->body); + $this->assertSame('internal_error', JsonAssert::at($tree, 'contexts', 'trace', 'status')); + } + + #[Group('ac-06-36')] + public function testErrorEventWithoutATransactionHasNoParentSpan(): void + { + $event = ExporterFixtures::errorEvent(); + $error = $this->payload()->errorEvent($event, null); + $tree = $this->tree($error['body']); + $trace = JsonAssert::at($tree, 'contexts', 'trace'); + + $this->assertInstanceOf(\stdClass::class, $trace); + $this->assertSame(['trace_id', 'span_id', 'op'], array_keys(get_object_vars($trace))); + $this->assertSame(TraceContext::traceId(ExporterFixtures::SESSION), JsonAssert::at($trace, 'trace_id')); + $this->assertSame(TraceContext::spanId(ExporterFixtures::EVENT), JsonAssert::at($trace, 'span_id')); + $this->assertSame('mcp:tools/call', JsonAssert::at($trace, 'op')); + $this->assertEqualsWithDelta(1749988805.123, JsonAssert::at($tree, 'timestamp'), PHP_FLOAT_EPSILON); + $this->assertSame('mcp:tools/call - get_weather', JsonAssert::at($tree, 'transaction')); + $this->assertSame('event', JsonAssert::at($tree, 'type')); + $this->assertSame('error', JsonAssert::at($tree, 'level')); + $this->assertSame($error['eventId'], JsonAssert::at($tree, 'event_id')); + $this->assertMatchesRegularExpression('/\A38fbea6b36e16f37[0-9a-f]{16}\z/', $error['eventId']); + $this->assertSame(['type' => 'mcp_tool_call', 'handled' => false], $this->vars(JsonAssert::at($tree, 'exception', 'values', 0, 'mechanism'))); + } + + #[Group('ac-06-36')] + public function testErrorEventWithATransactionUsesItsContext(): void + { + $transaction = new SentryTransaction('e', 'TRACE', 'SPAN', 'OP', 42, 'NAME', new \stdClass()); + $tree = $this->tree($this->payload(tracing: true)->errorEvent(ExporterFixtures::errorEvent(), $transaction)['body']); + $trace = JsonAssert::at($tree, 'contexts', 'trace'); + + $this->assertInstanceOf(\stdClass::class, $trace); + $this->assertSame(['trace_id', 'span_id', 'parent_span_id', 'op'], array_keys(get_object_vars($trace))); + $this->assertSame('TRACE', JsonAssert::at($trace, 'trace_id')); + $this->assertSame(TraceContext::spanId(ExporterFixtures::EVENT), JsonAssert::at($trace, 'span_id')); + $this->assertSame('SPAN', JsonAssert::at($trace, 'parent_span_id')); + $this->assertSame('OP', JsonAssert::at($trace, 'op')); + $this->assertSame(42, JsonAssert::at($tree, 'timestamp')); + $this->assertSame('NAME', JsonAssert::at($tree, 'transaction')); + } + + #[Group('ac-06-37')] + #[DataProvider('errors')] + public function testTheExceptionComesFromTheError(mixed $error, string $type, string $value): void + { + $event = ExporterFixtures::event(); + $event->isError = true; + $event->error = $error; + $values = JsonAssert::at($this->tree($this->payload()->errorEvent($event, null)['body']), 'exception', 'values'); + + $this->assertIsArray($values); + $this->assertCount(1, $values); + $this->assertSame($type, JsonAssert::at($values, 0, 'type')); + $this->assertSame($value, JsonAssert::at($values, 0, 'value')); + } + + /** @return iterable */ + public static function errors(): iterable + { + yield 'string' => ['x', 'ToolCallError', 'x']; + yield 'null' => [null, 'ToolCallError', 'Unknown error']; + yield 'empty string' => ['', 'ToolCallError', 'Unknown error']; + yield 'true' => [true, 'ToolCallError', 'Unknown error']; + yield 'number' => [7, 'ToolCallError', 'Unknown error']; + yield 'no message' => [Json::decode('{"code":5}'), 'ToolCallError', '{"code":5}']; + yield 'number message and boolean type' => [Json::decode('{"message":7,"type":true}'), 'true', '7']; + yield 'null message' => [Json::decode('{"message":null}'), 'ToolCallError', 'null']; + yield 'empty message' => [Json::decode('{"message":""}'), 'ToolCallError', '']; + yield 'type only' => [Json::decode('{"type":"X"}'), 'X', '{"type":"X"}']; + yield 'message and type' => [Json::decode('{"message":"m","type":"T"}'), 'T', 'm']; + yield 'list' => [['a'], 'ToolCallError', '["a"]']; + yield 'array with a message' => [['message' => 'm', 'type' => 'T'], 'T', 'm']; + yield 'object message' => [Json::decode('{"message":{"a":1}}'), 'ToolCallError', '[object Object]']; + yield 'list message' => [Json::decode('{"message":[1,null,"a"]}'), 'ToolCallError', '1,,a']; + yield 'float message' => [Json::decode('{"message":1.5}'), 'ToolCallError', '1.5']; + } + + #[Group('ac-06-38')] + public function testEnvironmentAndReleaseAreTagsRightAfterSource(): void + { + $payload = SentryPayload::of(new SentryExporter(self::DSN, 'production', '1.2.3', true)); + $event = ExporterFixtures::errorEvent(); + $transaction = $payload->transaction($event); + $error = $payload->errorEvent($event, $transaction); + + foreach ([$transaction->body, $error['body']] as $body) { + $tree = $this->tree($body); + $tags = JsonAssert::at($tree, 'tags'); + $this->assertInstanceOf(\stdClass::class, $tags); + $this->assertSame(['source', 'environment', 'release', 'event_type'], array_slice(array_keys(get_object_vars($tags)), 0, 4)); + $this->assertSame('production', JsonAssert::at($tags, 'environment')); + $this->assertSame('1.2.3', JsonAssert::at($tags, 'release')); + $this->assertFalse(property_exists($body, 'environment')); + $this->assertFalse(property_exists($body, 'release')); + } + $log = $payload->logEnvelope($event, 'X'); + $this->assertStringNotContainsString('production', $log); + $this->assertStringNotContainsString('1.2.3', $log); + } + + #[Group('ac-06-38')] + public function testEmptyOrMissingEnvironmentAndReleaseAddNoTag(): void + { + foreach ([[null, null], ['', '']] as [$environment, $release]) { + $payload = SentryPayload::of(new SentryExporter(self::DSN, $environment, $release, true)); + $tags = JsonAssert::at($this->tree($payload->transaction(ExporterFixtures::event())->body), 'tags'); + + $this->assertInstanceOf(\stdClass::class, $tags); + $this->assertSame(['source', 'event_type', 'resource', 'server_name', 'client_name', 'actor_id', 'agentcat.env', 'agentcat.Bad Key:x'], array_keys(get_object_vars($tags))); + } + } + + #[Group('ac-06-38')] + public function testTagsKeepCustomerKeysUnsanitizedAndSkipBlankFields(): void + { + $event = new Event(id: 'evt_1', eventType: 'mcp:ping', tags: ['A b' => 'v', 5 => 'five']); + $event->resourceName = ''; + $event->serverName = ''; + $event->clientName = ''; + $event->identifyActorGivenId = ''; + + $tags = JsonAssert::at($this->tree($this->payload(tracing: true)->transaction($event)->body), 'tags'); + + $this->assertInstanceOf(\stdClass::class, $tags); + $this->assertSame(['source' => 'agentcat', 'event_type' => 'mcp:ping', 'agentcat.A b' => 'v', 'agentcat.5' => 'five'], get_object_vars($tags)); + } + + public function testATagKeyStartingWithNulIsLeftOutAndTheEnvelopeIsStillBuilt(): void + { + $event = new Event(id: 'evt_1', eventType: 'mcp:ping', tags: ["\0bad" => 'x', 'ok' => 'y']); + $tags = JsonAssert::at($this->tree($this->payload(tracing: true)->transaction($event)->body), 'tags'); + + $this->assertSame(['source' => 'agentcat', 'event_type' => 'mcp:ping', 'agentcat.ok' => 'y'], $this->vars($tags)); + $this->assertSame(['source' => 'agentcat', 'event_type' => 'mcp:ping', 'agentcat.ok' => 'y'], $this->vars(JsonAssert::at($this->tree($this->payload()->errorEvent($event, null)['body']), 'tags'))); + } + + public function testExtraHoldsTheTruthyFieldsAndAnyDuration(): void + { + $event = ExporterFixtures::errorEvent(); + $extra = JsonAssert::at($this->tree($this->payload(tracing: true)->transaction($event)->body), 'extra'); + + $this->assertInstanceOf(\stdClass::class, $extra); + $this->assertSame(['session_id', 'project_id', 'user_intent', 'actor_name', 'server_version', 'client_version', 'duration_ms', 'error'], array_keys(get_object_vars($extra))); + + $blank = new Event(id: 'evt_1', projectId: '', eventType: '', duration: 0, error: ''); + $blank->userIntent = ''; + $blank->identifyActorName = ''; + $blank->serverVersion = ''; + $blank->clientVersion = ''; + $extra = JsonAssert::at($this->tree($this->payload(tracing: true)->transaction($blank)->body), 'extra'); + + $this->assertInstanceOf(\stdClass::class, $extra); + $this->assertSame(['duration_ms' => 0], get_object_vars($extra)); + } + + #[Group('ac-06-46')] + public function testAnEventWithOnlyAnIdAndNoTypeHasEmptyMaps(): void + { + $event = new Event(id: 'evt_1', eventType: ''); + $payload = $this->payload(tracing: true); + $log = $payload->logEnvelope($event, 'X'); + $transaction = $payload->transaction($event); + $error = $payload->errorEvent($event, null); + + $this->assertStringContainsString('"body":"MCP event","attributes":{}}', $log); + $this->assertStringContainsString('"extra":{}', ExportJson::encode($transaction->body)); + $this->assertSame('mcp.event', $transaction->name); + $this->assertSame('mcp.event', $transaction->op); + $this->assertSame('{"session_id":"","event_type":""}', ExportJson::encode(JsonAssert::at($this->tree($error['body']), 'contexts', 'mcp'))); + $this->assertSame('mcp.event', JsonAssert::at($this->tree($error['body']), 'contexts', 'trace', 'op')); + $this->assertSame('mcp.event', JsonAssert::at($this->tree($error['body']), 'transaction')); + $this->assertStringContainsString('"extra":{}', ExportJson::encode($error['body'])); + $this->assertStringNotContainsString('agentcat":', ExportJson::encode($transaction->body)); + } + + #[Group('ac-06-46')] + public function testEmptyPropertiesAreAnEmptyContext(): void + { + $event = ExporterFixtures::event(); + $event->properties = []; + $this->assertStringContainsString('"agentcat":{}', ExportJson::encode($this->payload(tracing: true)->transaction($event)->body)); + + $event->properties = null; + $this->assertStringNotContainsString('"agentcat":{', ExportJson::encode($this->payload(tracing: true)->transaction($event)->body)); + } + + public function testTheMcpContextKeepsTheNonNullFieldsInOrder(): void + { + $mcp = JsonAssert::at($this->tree($this->payload()->errorEvent(ExporterFixtures::errorEvent(), null)['body']), 'contexts', 'mcp'); + + $this->assertInstanceOf(\stdClass::class, $mcp); + $this->assertSame(['resource_name', 'session_id', 'event_type', 'user_intent'], array_keys(get_object_vars($mcp))); + + $event = new Event(id: 'evt_1', eventType: 'mcp:ping'); + $mcp = JsonAssert::at($this->tree($this->payload()->errorEvent($event, null)['body']), 'contexts', 'mcp'); + $this->assertInstanceOf(\stdClass::class, $mcp); + $this->assertSame(['session_id' => '', 'event_type' => 'mcp:ping'], get_object_vars($mcp)); + } + + public function testTheTransactionNameFollowsTheResource(): void + { + $event = new Event(id: 'evt_1', eventType: ''); + $event->resourceName = 'r'; + $this->assertSame('mcp - r', $this->payload(tracing: true)->transaction($event)->name); + $this->assertSame('mcp - r', JsonAssert::at($this->tree($this->payload()->errorEvent($event, null)['body']), 'transaction')); + + $event->eventType = 'mcp:ping'; + $this->assertSame('mcp:ping - r', $this->payload(tracing: true)->transaction($event)->name); + + $event->resourceName = ''; + $this->assertSame('mcp:ping', $this->payload(tracing: true)->transaction($event)->name); + $this->assertSame('mcp:ping', JsonAssert::at($this->tree($this->payload()->errorEvent($event, null)['body']), 'transaction')); + } + + public function testASessionlessEventGetsRandomTraceIds(): void + { + $event = new Event(id: 'evt_1', eventType: 'mcp:ping'); + $payload = $this->payload(tracing: true); + $transaction = $payload->transaction($event); + $error = $payload->errorEvent($event, null); + + $this->assertMatchesRegularExpression('/\A[0-9a-f]{32}\z/', $transaction->traceId); + $this->assertSame($transaction->traceId, JsonAssert::at($this->tree($transaction->body), 'contexts', 'trace', 'trace_id')); + $traceId = JsonAssert::at($this->tree($error['body']), 'contexts', 'trace', 'trace_id'); + $this->assertIsString($traceId); + $this->assertMatchesRegularExpression('/\A[0-9a-f]{32}\z/', $traceId); + $this->assertNotSame($transaction->traceId, $traceId); + } + + public function testTheLogLevelBodyAndIdsFollowTheEvent(): void + { + $payload = $this->payload(); + $event = ExporterFixtures::event(); + $log = $this->log($payload->logEnvelope($event, 'X')); + + $this->assertSame('info', JsonAssert::at($log, 'level')); + $this->assertSame('MCP mcp:tools/call: get_weather', JsonAssert::at($log, 'body')); + $this->assertSame(TraceContext::traceId(ExporterFixtures::SESSION), JsonAssert::at($log, 'trace_id')); + $this->assertSame(['timestamp', 'trace_id', 'event_id', 'level', 'body', 'attributes'], array_keys(get_object_vars($log))); + + $event->isError = true; + $this->assertSame('error', JsonAssert::at($this->log($payload->logEnvelope($event, 'X')), 'level')); + $event->resourceName = ''; + $event->eventType = ''; + $this->assertSame('MCP event', JsonAssert::at($this->log($payload->logEnvelope($event, 'X')), 'body')); + $event->eventType = 'mcp:tools/list'; + $this->assertSame('MCP mcp:tools/list', JsonAssert::at($this->log($payload->logEnvelope($event, 'X')), 'body')); + $event->eventType = ''; + $event->resourceName = 'r'; + $this->assertSame('MCP event: r', JsonAssert::at($this->log($payload->logEnvelope($event, 'X')), 'body')); + } + + public function testTheLogOfASessionlessEventHasARandomTraceId(): void + { + $log = $this->log($this->payload()->logEnvelope(new Event(id: 'evt_1', eventType: 'mcp:ping'), 'X')); + + $traceId = JsonAssert::at($log, 'trace_id'); + $this->assertIsString($traceId); + $this->assertMatchesRegularExpression('/\A[0-9a-f]{32}\z/', $traceId); + } + + public function testBlankLogFieldsAreLeftOut(): void + { + $event = new Event(id: 'evt_1', eventType: ''); + $event->projectId = ''; + $event->resourceName = ''; + $event->serverName = ''; + $event->clientName = ''; + $event->userIntent = ''; + $event->identifyActorGivenId = ''; + $event->identifyActorName = ''; + $event->serverVersion = ''; + $event->clientVersion = ''; + + $this->assertSame([], get_object_vars($this->attributes($event))); + } + + public function testEachLogFieldHasItsOwnAttribute(): void + { + $attributes = $this->attributes(ExporterFixtures::event()); + + $this->assertSame( + ['eventType', 'resourceName', 'serverName', 'clientName', 'sessionId', 'projectId', 'duration_ms', 'actorId', 'actorName', 'userIntent', 'serverVersion', 'clientVersion', 'isError'], + array_keys(get_object_vars($attributes)), + ); + $expected = [ + 'eventType' => 'mcp:tools/call', 'resourceName' => 'get_weather', 'serverName' => 'weather-server', 'clientName' => 'claude-desktop', + 'sessionId' => ExporterFixtures::SESSION, 'projectId' => 'proj_golden', 'actorId' => 'user_abc', 'actorName' => 'Alice', + 'userIntent' => 'Checking weather', 'serverVersion' => '1.0.0', 'clientVersion' => '2.0.0', + ]; + foreach ($expected as $key => $value) { + $this->assertSame($value, JsonAssert::at($attributes, $key, 'value'), $key); + $this->assertSame('string', JsonAssert::at($attributes, $key, 'type'), $key); + } + } + + public function testDurationAndErrorAttributes(): void + { + $event = ExporterFixtures::event(); + $event->duration = 0; + $this->assertSame('{"value":0,"type":"double"}', ExportJson::encode(JsonAssert::at($this->attributes($event), 'duration_ms'))); + + $event->duration = null; + $event->isError = null; + $attributes = $this->attributes($event); + $this->assertFalse(property_exists($attributes, 'duration_ms')); + $this->assertFalse(property_exists($attributes, 'isError')); + + $event->isError = true; + $this->assertSame('{"value":true,"type":"boolean"}', ExportJson::encode(JsonAssert::at($this->attributes($event), 'isError'))); + $event->isError = false; + $this->assertSame('{"value":false,"type":"boolean"}', ExportJson::encode(JsonAssert::at($this->attributes($event), 'isError'))); + } + + public function testEveryEnvelopeKeepsTheSentAtItWasGiven(): void + { + $payload = $this->payload(tracing: true); + $event = ExporterFixtures::errorEvent(); + + $this->assertStringContainsString('"sent_at":"S1"', $payload->logEnvelope($event, 'S1')); + $this->assertStringContainsString('"sent_at":"S2"', $payload->transactionEnvelope($payload->transaction($event), 'S2')); + $this->assertStringContainsString('"sent_at":"S3"', $payload->errorEnvelope($payload->errorEvent($event, null), 'S3')); + } + + private function payload(bool $tracing = false): SentryPayload + { + return SentryPayload::of(new SentryExporter(self::DSN, enableTracing: $tracing)); + } + + /** @return array */ + private function vars(mixed $object): array + { + $this->assertInstanceOf(\stdClass::class, $object); + + return get_object_vars($object); + } + + private function tree(\stdClass $body): \stdClass + { + $tree = Json::decode(ExportJson::encode($body)); + $this->assertInstanceOf(\stdClass::class, $tree); + + return $tree; + } + + private function log(string $envelope): \stdClass + { + $items = JsonAssert::at(Json::decode(explode("\n", $envelope)[2] ?? ''), 'items', 0); + $this->assertInstanceOf(\stdClass::class, $items); + + return $items; + } + + private function attributes(Event $event): \stdClass + { + $attributes = JsonAssert::at($this->log($this->payload()->logEnvelope($event, 'X')), 'attributes'); + $this->assertInstanceOf(\stdClass::class, $attributes); + + return $attributes; + } +} diff --git a/tests/Unit/Internal/Telemetry/SentryExportTest.php b/tests/Unit/Internal/Telemetry/SentryExportTest.php new file mode 100644 index 0000000..af732f5 --- /dev/null +++ b/tests/Unit/Internal/Telemetry/SentryExportTest.php @@ -0,0 +1,278 @@ +logger = new MemoryLogger(); + Log::setLogger($this->logger); + } + + #[\Override] + protected function tearDown(): void + { + Log::reset(); + } + + #[Group('ac-06-31')] + public function testTheConstructorLogsTheEndpoint(): void + { + $this->exporter(new RecordingHttpChannel()); + + $this->assertSame([self::INIT], $this->logger->messages()); + } + + #[Group('ac-06-34')] + public function testASuccessEventWithoutTracingSendsTheLogOnly(): void + { + $http = new RecordingHttpChannel(); + $event = ExporterFixtures::event(); + + $this->export($http, $event); + + $this->assertCount(1, $http->requests); + $request = $http->requests[0]; + $payload = SentryPayload::of(new SentryExporter(self::DSN)); + $this->assertSame(self::ENDPOINT, $request->url); + $this->assertSame($payload->headers(), $request->headers); + $this->assertSame($payload->logEnvelope($event, $this->sentAt($request)), $request->body); + $this->assertSame([ + self::INIT, + 'SentryExporter: Sending log for event ' . ExporterFixtures::EVENT . ' to Sentry', + 'Sentry log export success - Event: ' . ExporterFixtures::EVENT, + ], $this->logger->messages()); + } + + #[Group('ac-06-35')] + public function testAnErrorEventWithTracingSendsTheLogTheTransactionAndTheEventOneAfterTheOther(): void + { + $http = new RecordingHttpChannel(); + $exporter = $this->exporter($http, tracing: true); + + $exporter->export(ExporterFixtures::errorEvent(), $this->neverRejects()); + $this->assertCount(1, $http->requests, 'the transaction waits for the answer to the log'); + $http->pump(); + + $this->assertSame([self::LOG_ITEM, self::TRANSACTION_ITEM, self::EVENT_ITEM], array_map($this->itemHeader(...), $http->requests)); + $this->assertSame([self::ENDPOINT, self::ENDPOINT, self::ENDPOINT], $http->urls()); + $this->assertCount(3, $http->requests); + $transaction = $this->eventId($http->requests[1]); + $error = $this->eventId($http->requests[2]); + $this->assertNotSame($transaction, $error, 'the error event builds its own transaction (TS)'); + $this->assertSame(self::SPAN, TraceContext::spanId(ExporterFixtures::EVENT)); + $this->assertStringStartsWith(self::SPAN, $transaction); + $this->assertStringStartsWith(self::SPAN, $error); + $this->assertStringContainsString('"parent_span_id":"' . self::SPAN . '"', $http->requests[2]->body); + $this->assertSame([ + self::INIT, + 'SentryExporter: Sending log for event ' . ExporterFixtures::EVENT . ' to Sentry', + 'Sentry log export success - Event: ' . ExporterFixtures::EVENT, + "SentryExporter: Sending transaction {$transaction} to Sentry", + 'Sentry transaction export success - Event: ' . ExporterFixtures::EVENT, + "SentryExporter: Sending error event {$error} to Sentry for Issue creation", + 'Sentry error export success - Event: ' . ExporterFixtures::EVENT, + ], $this->logger->messages()); + } + + #[Group('ac-06-36')] + public function testAnErrorEventWithoutTracingSendsTheLogAndTheEvent(): void + { + $http = new RecordingHttpChannel(); + + $this->export($http, ExporterFixtures::errorEvent()); + + $this->assertSame([self::LOG_ITEM, self::EVENT_ITEM], array_map($this->itemHeader(...), $http->requests)); + $this->assertCount(2, $http->requests); + $this->assertStringNotContainsString('parent_span_id', $http->requests[1]->body); + $error = $this->eventId($http->requests[1]); + $this->assertSame([ + self::INIT, + 'SentryExporter: Sending log for event ' . ExporterFixtures::EVENT . ' to Sentry', + 'Sentry log export success - Event: ' . ExporterFixtures::EVENT, + "SentryExporter: Sending error event {$error} to Sentry for Issue creation", + 'Sentry error export success - Event: ' . ExporterFixtures::EVENT, + ], $this->logger->messages()); + } + + #[Group('ac-06-34')] + public function testASuccessEventWithTracingSendsTheLogAndTheTransaction(): void + { + $http = new RecordingHttpChannel(); + + $this->export($http, ExporterFixtures::event(), tracing: true); + + $this->assertSame([self::LOG_ITEM, self::TRANSACTION_ITEM], array_map($this->itemHeader(...), $http->requests)); + } + + #[Group('ac-06-39')] + public function testANetworkErrorOnTheLogEndsTheSequence(): void + { + $http = new RecordingHttpChannel([HttpResponse::failed('cURL error 7: x')]); + + $this->export($http, ExporterFixtures::errorEvent(), tracing: true); + + $this->assertCount(1, $http->requests); + $this->assertSame([ + self::INIT, + 'SentryExporter: Sending log for event ' . ExporterFixtures::EVENT . ' to Sentry', + 'Sentry export error: cURL error 7: x', + ], $this->logger->messages()); + } + + #[Group('ac-06-39')] + public function testANetworkErrorOnTheTransactionEndsTheSequence(): void + { + $http = new RecordingHttpChannel([new HttpResponse(200, 'OK', ''), HttpResponse::failed('cURL error 28: y')]); + + $this->export($http, ExporterFixtures::errorEvent(), tracing: true); + + $this->assertSame([self::LOG_ITEM, self::TRANSACTION_ITEM], array_map($this->itemHeader(...), $http->requests)); + $this->assertCount(2, $http->requests); + $this->assertSame([ + self::INIT, + 'SentryExporter: Sending log for event ' . ExporterFixtures::EVENT . ' to Sentry', + 'Sentry log export success - Event: ' . ExporterFixtures::EVENT, + 'SentryExporter: Sending transaction ' . $this->eventId($http->requests[1]) . ' to Sentry', + 'Sentry export error: cURL error 28: y', + ], $this->logger->messages()); + } + + public function testANonSuccessStatusLogsTheBodyAndTheSequenceGoesOn(): void + { + $http = new RecordingHttpChannel([ + new HttpResponse(500, 'Internal Server Error', 'oops'), + new HttpResponse(502, '', 'bad gateway'), + new HttpResponse(429, 'Too Many Requests', 'slow down'), + ]); + + $this->export($http, ExporterFixtures::errorEvent(), tracing: true); + + $this->assertSame([self::LOG_ITEM, self::TRANSACTION_ITEM, self::EVENT_ITEM], array_map($this->itemHeader(...), $http->requests)); + $this->assertCount(3, $http->requests); + $this->assertSame([ + self::INIT, + 'SentryExporter: Sending log for event ' . ExporterFixtures::EVENT . ' to Sentry', + 'Sentry log export failed - Status: 500, Body: oops', + 'SentryExporter: Sending transaction ' . $this->eventId($http->requests[1]) . ' to Sentry', + 'Sentry transaction export failed - Status: 502, Body: bad gateway', + 'SentryExporter: Sending error event ' . $this->eventId($http->requests[2]) . ' to Sentry for Issue creation', + 'Sentry error export failed - Status: 429, Body: slow down', + ], $this->logger->messages()); + } + + public function testATransactionThatCannotBeEncodedEndsTheSequenceWithTheCatchLine(): void + { + $http = new RecordingHttpChannel(); + + $this->export($http, $this->cyclicEvent(), tracing: true); + + $this->assertSame([self::LOG_ITEM], array_map($this->itemHeader(...), $http->requests)); + $this->assertSame([ + self::INIT, + 'SentryExporter: Sending log for event ' . ExporterFixtures::EVENT . ' to Sentry', + 'Sentry log export success - Event: ' . ExporterFixtures::EVENT, + 'Sentry export error: UnexpectedValueException: Converting circular structure to JSON', + ], $this->logger->messages(), 'TS builds the envelope before its "Sending" line'); + } + + public function testAnErrorEventThatCannotBeEncodedEndsTheSequenceWithTheCatchLine(): void + { + $http = new RecordingHttpChannel(); + + $this->export($http, $this->cyclicEvent()); + + $this->assertSame([self::LOG_ITEM], array_map($this->itemHeader(...), $http->requests)); + $this->assertSame([ + self::INIT, + 'SentryExporter: Sending log for event ' . ExporterFixtures::EVENT . ' to Sentry', + 'Sentry log export success - Event: ' . ExporterFixtures::EVENT, + 'Sentry export error: UnexpectedValueException: Converting circular structure to JSON', + ], $this->logger->messages(), 'TS builds the envelope before its "Sending" line'); + } + + private function cyclicEvent(): Event + { + $cycle = new \stdClass(); + $cycle->self = $cycle; + $event = ExporterFixtures::errorEvent(); + $event->properties = $cycle; // only the transaction and the error event carry properties + + return $event; + } + + private function export(RecordingHttpChannel $http, Event $event, bool $tracing = false): void + { + $this->exporter($http, $tracing)->export($event, $this->neverRejects()); + $http->pump(); + } + + private function exporter(RecordingHttpChannel $http, bool $tracing = false): SentryExport + { + return new SentryExport(SentryPayload::of(new SentryExporter(self::DSN, enableTracing: $tracing)), $http); + } + + /** @return \Closure(string): void */ + private function neverRejects(): \Closure + { + return static function (string $message): never { + self::fail('Sentry logs its own failures and never rejects: ' . $message); + }; + } + + private function itemHeader(HttpRequest $request): string + { + return explode("\n", $request->body)[1] ?? ''; + } + + private function eventId(HttpRequest $request): string + { + $this->assertSame(1, preg_match(self::ID, explode("\n", $request->body)[0], $match)); + + return $match[1]; + } + + private function sentAt(HttpRequest $request): string + { + $this->assertSame(1, preg_match('/"sent_at":"([^"]+)"/', $request->body, $match)); + + return $match[1]; + } +} diff --git a/tests/Unit/Internal/Telemetry/TelemetryManagerTest.php b/tests/Unit/Internal/Telemetry/TelemetryManagerTest.php new file mode 100644 index 0000000..9cb7ce7 --- /dev/null +++ b/tests/Unit/Internal/Telemetry/TelemetryManagerTest.php @@ -0,0 +1,370 @@ +logger = new MemoryLogger(); + Log::setLogger($this->logger); + } + + #[\Override] + protected function tearDown(): void + { + Log::reset(); + } + + #[Group('ac-06-10')] + public function testEveryConfigTypeBuildsItsExporterAndEveryExporterSends(): void + { + $http = new RecordingHttpChannel(); + $manager = TelemetryManager::fromConfigs([ + 'otlp' => new OtlpExporter('http://otlp.test'), + 'dd' => new DatadogExporter('k', 'datadoghq.com', 'svc'), + 'sentry' => new SentryExporter('https://abc@o1.ingest.sentry.io/42'), + 'ph' => new PostHogExporter('phc_x'), + ], $http); + + $this->assertSame(4, $manager->count()); + $this->assertSame([ + 'Initialized telemetry exporter: otlp', + 'Initialized telemetry exporter: dd', + 'SentryExporter: Initialized with endpoint ' . self::SENTRY, + 'Initialized telemetry exporter: sentry', + 'PostHogExporter: Initialized with endpoint https://us.i.posthog.com/batch', + 'Initialized telemetry exporter: ph', + ], $this->logger->messages()); + + $manager->export(ExporterFixtures::event()); + $http->pump(); + + $this->assertSame([ + 'http://otlp.test/v1/traces', + 'https://http-intake.logs.datadoghq.com/api/v2/logs', + 'https://api.datadoghq.com/api/v1/series', + self::SENTRY, + 'https://us.i.posthog.com/batch', + ], $http->urls()); + foreach ($http->requests as $request) { + // D49: exports keep the channel's 2 s connect and 10 s total timeouts, and flush() waits for them. + $this->assertSame([false, null, null], [$request->background, $request->connectTimeoutSeconds, $request->totalTimeoutSeconds], $request->url); + } + } + + #[Group('ac-06-10')] + public function testAnUnknownConfigIsSkippedAndTheOthersAreBuilt(): void + { + $manager = TelemetryManager::fromConfigs(['a' => new OtlpExporter('http://h'), 'b' => new \stdClass()], new RecordingHttpChannel()); + + $this->assertSame(1, $manager->count()); + $this->assertSame(['Initialized telemetry exporter: a', 'Unknown exporter type: stdClass'], $this->logger->messages()); + } + + /** Task 4: `new Options(exporters: […])` does not validate its entries; anything else is skipped with its type. */ + #[DataProvider('unknownConfigs')] + #[Group('ac-06-10')] + public function testAnyEntryThatIsNotAnExporterConfigIsSkippedWithItsType(mixed $config, string $type): void + { + $manager = TelemetryManager::fromConfigs(['x' => $config], new RecordingHttpChannel()); + + $this->assertSame(0, $manager->count()); + $this->assertSame(['Unknown exporter type: ' . $type], $this->logger->messages()); + } + + /** @return iterable */ + public static function unknownConfigs(): iterable + { + yield 'null' => [null, 'null']; + yield 'a config array (fromArray() maps these, the constructor does not)' => [['type' => 'otlp', 'endpoint' => 'http://h'], 'array']; + yield 'a lazy factory' => [static fn(): OtlpExporter => new OtlpExporter('http://h'), 'Closure']; + yield 'a string' => ['otlp', 'string']; + yield 'an object' => [new \ArrayObject(), 'ArrayObject']; + } + + public function testIntegerKeysNameTheExporterLikeJavaScriptObjectKeys(): void + { + $http = new RecordingHttpChannel([new HttpResponse(500, 'Internal Server Error', '')]); + $manager = TelemetryManager::fromConfigs([new OtlpExporter('http://h'), 5 => new SentryExporter('bad')], $http); + + $manager->export(ExporterFixtures::event()); + $http->pump(); + + $this->assertSame([ + 'Initialized telemetry exporter: 0', + 'Failed to initialize exporter 5: InvalidArgumentException: Invalid Sentry DSN: bad', + 'Telemetry export failed for 0: OTLP export error: Error: OTLP export failed: 500 Internal Server Error', + ], $this->logger->messages()); + } + + #[Group('ac-06-11')] + public function testAnInvalidDsnDoesNotStopTheNextExporter(): void + { + $http = new RecordingHttpChannel(); + $manager = TelemetryManager::fromConfigs([ + 'sentry' => new SentryExporter('https://ABC@o1.ingest.sentry.io/42'), + 'ph' => new PostHogExporter('phc_x'), + ], $http); + + $manager->export(ExporterFixtures::event()); + $http->pump(); + + $this->assertSame(1, $manager->count()); + $this->assertSame(['https://us.i.posthog.com/batch'], $http->urls()); + $this->assertSame([ + 'Failed to initialize exporter sentry: InvalidArgumentException: Invalid Sentry DSN: https://***@o1.ingest.sentry.io/42', + 'PostHogExporter: Initialized with endpoint https://us.i.posthog.com/batch', + 'Initialized telemetry exporter: ph', + 'PostHogExporter: Sending 1 event(s) for ' . ExporterFixtures::EVENT, + 'PostHog export success - Event: ' . ExporterFixtures::EVENT, + ], $this->logger->messages()); + } + + #[DataProvider('thrown')] + #[Group('ac-06-12')] + public function testAThrowingExporterIsLoggedWithTheTypeScriptMessageAndTheNextOneRuns(\Throwable $error, string $message): void + { + $throwing = new readonly class ($error) implements Exporter { + public function __construct(private \Throwable $error) {} + + #[\Override] + public function export(Event $event, \Closure $reject): void + { + throw $this->error; + } + }; + $recording = new class implements Exporter { + /** @var list */ + public array $calls = []; + + #[\Override] + public function export(Event $event, \Closure $reject): void + { + $this->calls[] = $event->id; + } + }; + + (new TelemetryManager(['first' => $throwing, 'second' => $recording]))->export(ExporterFixtures::event()); + + $this->assertSame(['Telemetry export failed for first: ' . $message], $this->logger->messages()); + $this->assertSame([ExporterFixtures::EVENT], $recording->calls); + } + + /** @return iterable */ + public static function thrown(): iterable + { + yield 'a PHP exception: its message (TS error.message)' => [new \RuntimeException('boom'), 'boom']; + yield 'a JavaScript error: its message without the name' => [new JsError('TypeError', 'value.replace is not a function'), 'value.replace is not a function']; + } + + #[Group('ac-06-12')] + public function testARejectionIsLoggedUnderTheNameOfItsExporter(): void + { + $rejecting = new class implements Exporter { + #[\Override] + public function export(Event $event, \Closure $reject): void + { + $reject('nope ' . $event->id); + } + }; + + (new TelemetryManager(['a' => $rejecting, 7 => $rejecting]))->export(ExporterFixtures::event()); + + $this->assertSame([ + 'Telemetry export failed for a: nope ' . ExporterFixtures::EVENT, + 'Telemetry export failed for 7: nope ' . ExporterFixtures::EVENT, + ], $this->logger->messages()); + } + + public function testAManagerWithoutExportersDoesNothing(): void + { + $manager = new TelemetryManager([]); + + $manager->export(ExporterFixtures::event()); + + $this->assertSame(0, $manager->count()); + $this->assertSame([], $this->logger->messages()); + } + + /** Review Focus 4: values JSON cannot carry as they are. */ + public function testValuesThatJsonCannotCarryNeverStopTheOtherExporters(): void + { + $http = new RecordingHttpChannel(); + $manager = TelemetryManager::fromConfigs([ + 'otlp' => new OtlpExporter('http://otlp.test'), + 'dd' => new DatadogExporter('k', 'datadoghq.com', 'svc'), + 'sentry' => new SentryExporter('https://abc@o1.ingest.sentry.io/42', enableTracing: true), + 'ph' => new PostHogExporter('phc_x', enableAITracing: true), + ], $http); + $self = ['a' => 1]; + $self['self'] = &$self; + $event = ExporterFixtures::errorEvent(); + $event->resourceName = "caf\xE9"; + $event->tags = ['t' => "x\xFF"]; + $event->properties = ['n' => NAN, 'i' => INF]; + $event->error = ['f' => static fn(): int => 1, 'r' => STDIN]; + $event->parameters = $self; + + $manager->export($event); + $http->pump(); + + foreach ($http->requests as $request) { + foreach (explode("\n", rtrim($request->body, "\n")) as $line) { + $this->assertJson($line, $request->url); + } + } + $failures = array_values(array_filter($this->logger->messages(), static fn(string $line): bool => preg_match('/failed|export error|network error/', $line) === 1)); + $this->assertSame(['PostHog export error: UnexpectedValueException: Maximum nesting depth of 512 exceeded'], $failures, 'only PostHog sends parameters, and it logs exactly one line'); + $this->assertSame([ + 'http://otlp.test/v1/traces', + 'https://http-intake.logs.datadoghq.com/api/v2/logs', + 'https://api.datadoghq.com/api/v1/series', + self::SENTRY, + self::SENTRY, + self::SENTRY, + ], $http->urls(), 'OTLP, both Datadog requests and the Sentry log, transaction and error event are sent'); + $this->assertCount(6, $http->requests); + $this->assertStringContainsString("caf\u{FFFD}", $http->requests[0]->body); + $this->assertStringContainsString('"properties":{"n":null,"i":null}', $http->requests[1]->body); + $this->assertStringContainsString('"error":{"r":"[Resource: stream]"}', $http->requests[1]->body); + $this->assertStringContainsString('"agentcat.t":"x' . "\u{FFFD}" . '"', $http->requests[5]->body); + } + + public function testInstallIsFirstWinsAndWarnsOnceForADifferentSet(): void + { + $queue = $this->queue(); + $first = ['otlp' => new OtlpExporter('http://a')]; + + TelemetryManager::install($queue, null); + $this->assertSame([], $this->logger->messages()); + TelemetryManager::install($queue, $first); + $manager = $queue->exporterSink(); + TelemetryManager::install($queue, ['otlp' => new OtlpExporter('http://a')]); + TelemetryManager::install($queue, ['otlp' => new OtlpExporter('http://b')]); + TelemetryManager::install($queue, ['x' => new OtlpExporter('http://c')]); + TelemetryManager::install($queue, null); + + $this->assertInstanceOf(TelemetryManager::class, $manager); + $this->assertSame($manager, $queue->exporterSink()); + $this->assertSame([ + 'Initialized telemetry exporter: otlp', + 'Initialized telemetry with 1 exporters', + 'WARN: AgentCat exporters were set up by an earlier track(); the exporters of this call are ignored', + ], $this->logger->messages()); + } + + public function testInstallSendsThroughTheChannelOfTheQueue(): void + { + $http = new RecordingHttpChannel(); + $queue = new EventQueue(transport: new RecordingTransport(), channel: $http); + + TelemetryManager::install($queue, ['otlp' => new OtlpExporter('http://a')]); + $queue->exporterSink()->export(ExporterFixtures::event()); + + $this->assertSame(['http://a/v1/traces'], $http->urls()); + } + + /** Spec conflict 5: `exporters: []` is TypeScript `{}`, which is truthy: an empty manager. */ + public function testAnEmptySetInstallsAnEmptyManager(): void + { + $queue = $this->queue(); + + TelemetryManager::install($queue, []); + TelemetryManager::install($queue, []); + + $manager = $queue->exporterSink(); + $this->assertInstanceOf(TelemetryManager::class, $manager); + $this->assertSame(0, $manager->count()); + $this->assertSame(['Initialized telemetry with 0 exporters'], $this->logger->messages()); + } + + /** Spec conflict 5: the count is of the entries that reached `Options::$exporters`, built or not. */ + public function testTheInstallCountIsOfTheEntriesGiven(): void + { + TelemetryManager::install($this->queue(), ['otlp' => new OtlpExporter('http://a'), 'bad' => 'otlp']); + + $this->assertSame([ + 'Initialized telemetry exporter: otlp', + 'Unknown exporter type: string', + 'Initialized telemetry with 2 exporters', + ], $this->logger->messages()); + } + + public function testInstallLeavesAnotherSinkAlone(): void + { + $recorder = new RecordingExporter(); + $queue = new EventQueue(transport: new RecordingTransport(), exporters: $recorder, channel: new RecordingHttpChannel()); + + TelemetryManager::install($queue, ['otlp' => new OtlpExporter('http://a')]); + + $this->assertSame($recorder, $queue->exporterSink()); + $this->assertSame([], $this->logger->messages()); + } + + /** Ruling P3: a set that cannot be serialized counts as different; install() never throws (D32). */ + public function testASetThatCannotBeSerializedCountsAsDifferentAndNeverThrows(): void + { + $queue = $this->queue(); + $lazy = ['otlp' => static fn(): OtlpExporter => new OtlpExporter('http://a')]; + + TelemetryManager::install($queue, $lazy); + TelemetryManager::install($queue, $lazy); + TelemetryManager::install($queue, $lazy); + + $this->assertInstanceOf(TelemetryManager::class, $queue->exporterSink()); + $this->assertSame([ + 'Unknown exporter type: Closure', + 'Initialized telemetry with 1 exporters', + 'WARN: AgentCat exporters were set up by an earlier track(); the exporters of this call are ignored', + ], $this->logger->messages()); + } + + /** Ruling P3: a set whose serialization warns counts as different, and the warning reaches no output. */ + public function testASetWhoseSerializationWarnsCountsAsDifferent(): void + { + $queue = $this->queue(); + TelemetryManager::install($queue, ['otlp' => new OtlpExporter('http://a')]); + + TelemetryManager::install($queue, ['otlp' => new OtlpExporter('http://a'), 'odd' => new MissingSleepProperty()]); + + $this->assertSame([ + 'Initialized telemetry exporter: otlp', + 'Initialized telemetry with 1 exporters', + 'WARN: AgentCat exporters were set up by an earlier track(); the exporters of this call are ignored', + ], $this->logger->messages()); + } + + private function queue(): EventQueue + { + return new EventQueue(transport: new RecordingTransport(), channel: new RecordingHttpChannel()); + } +} diff --git a/tests/Unit/Internal/Telemetry/TraceContextTest.php b/tests/Unit/Internal/Telemetry/TraceContextTest.php new file mode 100644 index 0000000..6c98926 --- /dev/null +++ b/tests/Unit/Internal/Telemetry/TraceContextTest.php @@ -0,0 +1,69 @@ +assertSame('b17987c6b00767670c45519276a327c3', TraceContext::traceId(self::SESSION)); + } + + #[Group('ac-06-2')] + public function testTheSpanIdIsTheFirst8BytesOfTheEventHash(): void + { + $this->assertSame('38fbea6b36e16f37', TraceContext::spanId(self::EVENT)); + } + + #[Group('ac-06-3')] + public function testDatadogIdsAreTheLow64BitsAsUnsignedDecimals(): void + { + $this->assertSame('884202591345649603', TraceContext::datadogTraceId(self::SESSION)); + $this->assertSame('4106133231481483063', TraceContext::datadogSpanId(self::EVENT)); + } + + #[Group('ac-06-4')] + public function testValuesAboveTheSignedRangeStayUnsignedIntegers(): void + { + $this->assertSame('18446744073709551615', TraceContext::unsignedDecimal('ffffffffffffffff')); + $this->assertSame('9223372036854775808', TraceContext::unsignedDecimal('8000000000000000')); + $this->assertSame('9223372036854775807', TraceContext::unsignedDecimal('7fffffffffffffff')); + $this->assertSame('1', TraceContext::unsignedDecimal('0000000000000001')); + $this->assertSame('0', TraceContext::unsignedDecimal('0000000000000000')); + } + + #[Group('ac-06-5')] + public function testAnEmptySessionGetsANewRandomTraceIdEachTime(): void + { + $first = TraceContext::traceId(''); + $second = TraceContext::traceId(''); + $this->assertMatchesRegularExpression('/\A[0-9a-f]{32}\z/', $first); + $this->assertMatchesRegularExpression('/\A[0-9a-f]{32}\z/', $second); + $this->assertNotSame($first, $second); + $this->assertMatchesRegularExpression('/\A[0-9]{1,20}\z/', TraceContext::datadogTraceId('')); + } + + #[Group('ac-06-6')] + public function testAnEmptyEventIdGetsARandomSpanId(): void + { + $this->assertMatchesRegularExpression('/\A[0-9a-f]{16}\z/', TraceContext::spanId('')); + $this->assertNotSame(TraceContext::spanId(''), TraceContext::spanId('')); + $this->assertMatchesRegularExpression('/\A[0-9]{1,20}\z/', TraceContext::datadogSpanId('')); + } + + public function testNonAsciiIdsAreHashedAsUtf8Bytes(): void + { + $this->assertSame(substr(hash('sha256', 'séance'), 0, 32), TraceContext::traceId('séance')); + } +} diff --git a/tests/Unit/Internal/Transport/CurlHttpChannelUnitTest.php b/tests/Unit/Internal/Transport/CurlHttpChannelUnitTest.php new file mode 100644 index 0000000..a94e895 --- /dev/null +++ b/tests/Unit/Internal/Transport/CurlHttpChannelUnitTest.php @@ -0,0 +1,337 @@ +logger = new MemoryLogger(); + Log::setLogger($this->logger); + } + + #[\Override] + protected function tearDown(): void + { + Log::reset(); + } + + public function testAFullQueueDropsTheOldestRequestsWithOneLinePerPass(): void + { + // Every add is refused: one pump then fails each waiting request through its callback, and none is sent. + $swallowed = []; + $channel = $this->refusingChannel($swallowed); + $ran = []; + $post = static function (int $i) use ($channel, &$ran): void { + $channel->post(new HttpRequest('http://127.0.0.1:9/r' . $i, [], '{}'), static function () use (&$ran, $i): void { + $ran[] = $i; + }); + }; + + for ($i = 0; $i < CurlHttpChannel::MAX_WAITING + 3; ++$i) { + $post($i); + } + + $this->assertSame(1000, $channel->pending()); + $this->assertSame(0, $channel->inTransfer()); + $this->assertSame([], $this->logger->messages(), 'the line waits for the pass'); + $channel->pump(); + $this->assertSame([sprintf(self::FULL, 3)], $this->logger->messages(), 'one line for the pass, with its count'); + $this->assertSame(range(3, 1002), $ran, 'r0 to r2, the oldest, were dropped'); + $this->assertSame(0, $channel->pending()); + + $channel->pump(); + $this->assertCount(1, $this->logger->messages(), 'a pass without drops logs nothing'); + + for ($i = 0; $i < CurlHttpChannel::MAX_WAITING + 1; ++$i) { + $post(2000 + $i); + } + $channel->pump(); + $this->assertSame([sprintf(self::FULL, 3), sprintf(self::FULL, 1)], $this->logger->messages(), 'the count starts again'); + $this->assertSame([], $swallowed); + } + + /** A6: the waiting requests hold at most 16 MiB of bodies; the oldest go first, with the same line. */ + public function testTheWaitingBodiesStayWithinTheirByteBudget(): void + { + $swallowed = []; + $channel = $this->refusingChannel($swallowed); + $mib = str_repeat('x', 1_048_576); + $ran = []; + for ($i = 0; $i < 18; ++$i) { + $channel->post(new HttpRequest('http://127.0.0.1:9/r' . $i, [], $mib), static function () use (&$ran, $i): void { + $ran[] = $i; + }); + } + + $this->assertSame(16, $channel->pending(), '16 MiB of bodies'); + $channel->pump(); + $this->assertSame(range(2, 17), $ran, 'r0 and r1, the oldest, were dropped'); + $this->assertSame([sprintf(self::FULL, 2)], $this->logger->messages()); + + // The budget is free again once the requests have left the waiting list. + for ($i = 0; $i < 16; ++$i) { + $channel->post(new HttpRequest('http://127.0.0.1:9/s' . $i, [], $mib), static function (): void {}); + } + $this->assertSame(16, $channel->pending()); + $channel->pump(); + $this->assertCount(1, $this->logger->messages(), 'nothing dropped'); + $this->assertSame([], $swallowed); + } + + /** A6: a body larger than the whole budget still waits, alone: the newest request is never dropped. */ + public function testABodyOverTheBudgetWaitsAlone(): void + { + $swallowed = []; + $channel = $this->refusingChannel($swallowed); + $ran = []; + $record = static function (string $name) use (&$ran): \Closure { + return static function () use ($name, &$ran): void { + $ran[] = $name; + }; + }; + + $channel->post(new HttpRequest('http://127.0.0.1:9/small', [], '{}'), $record('small')); + $channel->post(new HttpRequest('http://127.0.0.1:9/huge', [], str_repeat('x', CurlHttpChannel::MAX_WAITING_BYTES + 1)), $record('huge')); + $this->assertSame(1, $channel->pending()); + $channel->post(new HttpRequest('http://127.0.0.1:9/next', [], '{}'), $record('next')); + $this->assertSame(1, $channel->pending()); + + $channel->pump(); + $this->assertSame(['next'], $ran); + $this->assertSame([sprintf(self::FULL, 2)], $this->logger->messages()); + $this->assertSame([], $swallowed); + } + + /** @return iterable */ + public static function badTimeouts(): iterable + { + yield 'connect NAN' => [NAN, 10.0]; + yield 'connect negative' => [-1.0, 10.0]; + yield 'total INF' => [2.0, INF]; + yield 'total zero (curl: no timeout)' => [2.0, 0.0]; + } + + /** M2: a timeout curl cannot take is a programming error; the queue builds the channel with the defaults. */ + #[DataProvider('badTimeouts')] + public function testATimeoutCurlCannotTakeIsRejectedAtConstruction(float $connectTimeoutSeconds, float $totalTimeoutSeconds): void + { + $error = Thrown::by(\InvalidArgumentException::class, static fn(): CurlHttpChannel => new CurlHttpChannel($connectTimeoutSeconds, $totalTimeoutSeconds)); + + $this->assertStringStartsWith('A curl timeout must round to 1 through 2147483647 milliseconds, got ', $error->getMessage()); + } + + public function testWithoutACurlFunctionEveryRequestIsDroppedWithOneLine(): void + { + $called = new \ArrayObject(); + $channel = new CurlHttpChannel(functionExists: static fn(string $function): bool => $function !== 'curl_multi_init'); + $onDone = static function () use ($called): void { + $called[] = true; + }; + + $channel->post(new HttpRequest('http://127.0.0.1:9/a', [], '{}'), $onDone); + $channel->post(new HttpRequest('http://127.0.0.1:9/b', [], '{}'), $onDone); + $channel->pump(); + $channel->waitForActivity(1.0); + + $this->assertSame(0, $channel->pending()); + $this->assertCount(0, $called); + $this->assertSame([self::NO_CURL . 'curl_multi_init)'], $this->logger->messages()); + } + + public function testTheLineNamesEveryMissingFunction(): void + { + $channel = new CurlHttpChannel(functionExists: static fn(string $function): bool => !in_array($function, ['curl_multi_strerror', 'curl_init'], true)); + + $channel->post(new HttpRequest('http://127.0.0.1:9', [], ''), static function (): void {}); + + $this->assertSame([self::NO_CURL . 'curl_init, curl_multi_strerror)'], $this->logger->messages()); + } + + public function testTheFunctionsAreCheckedOnceAtTheFirstPost(): void + { + $checked = new \ArrayObject(); + $channel = new CurlHttpChannel(functionExists: static function (string $function) use ($checked): bool { + $checked[] = $function; + + return function_exists($function); + }); + $channel->pump(); + $this->assertCount(0, $checked); + + $channel->post(new HttpRequest('http://127.0.0.1:9/a', [], ''), static function (): void {}); + $channel->post(new HttpRequest('http://127.0.0.1:9/b', [], ''), static function (): void {}); + + $this->assertSame( + ['curl_init', 'curl_setopt_array', 'curl_error', 'curl_strerror', 'curl_getinfo', 'curl_multi_init', 'curl_multi_add_handle', 'curl_multi_remove_handle', 'curl_multi_exec', 'curl_multi_info_read', 'curl_multi_select', 'curl_multi_strerror'], + $checked->getArrayCopy(), + ); + $this->assertSame(2, $channel->pending()); + $this->assertSame([], $this->logger->messages()); + } + + public function testAPumpWithNothingToSendRunsNoCurl(): void + { + $calls = new \ArrayObject(); + $multiStatus = static function (string $function, int $code) use ($calls): int { + $calls[] = $function; + + return $code; + }; + $channel = new CurlHttpChannel(multiStatus: $multiStatus); + $missing = new CurlHttpChannel(functionExists: static fn(string $function): bool => $function !== 'curl_multi_init', multiStatus: $multiStatus); + $missing->post(new HttpRequest('http://127.0.0.1:9', [], ''), static function (): void {}); + + $channel->pump(); + $missing->pump(); + + $this->assertCount(0, $calls); + $this->assertSame(0, $channel->pending()); + } + + /** @return iterable */ + public static function bounds(): iterable + { + yield 'NAN' => [NAN]; + yield 'negative' => [-1.0]; + yield 'one second' => [1.0]; + yield 'INF' => [INF]; + } + + /** A waiting request starts at the next pump(); with nothing in transfer there is nothing to wait for. */ + #[DataProvider('bounds')] + public function testAWaitWithNothingInTransferReturnsAtOnce(float $seconds): void + { + $channel = new CurlHttpChannel(); + $channel->post(new HttpRequest('http://127.0.0.1:9', [], ''), static function (): void {}); + $begin = hrtime(true); + + $channel->waitForActivity($seconds); + + $this->assertLessThan(0.05, (hrtime(true) - $begin) / 1e9); + $this->assertSame(1, $channel->pending()); + $this->assertSame(0, $channel->inTransfer()); + } + + /** @return iterable */ + public static function outcomes(): iterable + { + yield '200' => [200, null, true]; + yield '299' => [299, null, true]; + yield '199' => [199, null, false]; + yield '300' => [300, null, false]; + yield '200 with an error' => [200, 'cURL error 56: Recv failure', false]; + } + + #[DataProvider('outcomes')] + public function testAResponseIsOkForA2xxStatusWithoutAnError(int $status, ?string $error, bool $ok): void + { + $this->assertSame($ok, (new HttpResponse($status, 'R', 'b', $error))->ok()); + } + + public function testAFailedResponseCarriesOnlyItsError(): void + { + $response = HttpResponse::failed('x'); + + $this->assertSame(0, $response->status); + $this->assertSame('', $response->reason); + $this->assertSame('', $response->body); + $this->assertSame('x', $response->error); + $this->assertFalse($response->ok()); + } + + public function testAResponseWithoutAnErrorHasANullError(): void + { + $response = new HttpResponse(204, 'No Content', ''); + + $this->assertNull($response->error); + $this->assertTrue($response->ok()); + } + + public function testARequestKeepsItsHeadersInOrder(): void + { + $request = new HttpRequest('https://example.test/v1/logs', ['b' => '2', 'a' => '1'], '{}'); + + $this->assertSame('https://example.test/v1/logs', $request->url); + $this->assertSame(['b' => '2', 'a' => '1'], $request->headers); + $this->assertSame('{}', $request->body); + $this->assertFalse($request->background, 'an exporter request by default'); + } + + /** A9 (D49): a request can carry its own timeouts (diagnostics: 250 ms and 1 s); others get the channel's 2 s and 10 s. */ + public function testARequestCarriesItsOwnTimeoutsOrGetsTheChannels(): void + { + $channel = new CurlHttpChannel(); + $transfer = new HttpTransfer(static function (): void {}, false); + + $short = $channel->curlOptions(new HttpRequest('http://127.0.0.1:9/v1/logs', [], '', true, 0.25, 1.0), $transfer); + $long = $channel->curlOptions(new HttpRequest('http://127.0.0.1:9/v1/traces', [], ''), $transfer); + + $this->assertSame([250, 1000], [$short[CURLOPT_CONNECTTIMEOUT_MS] ?? null, $short[CURLOPT_TIMEOUT_MS] ?? null]); + $this->assertSame([2000, 10000], [$long[CURLOPT_CONNECTTIMEOUT_MS] ?? null, $long[CURLOPT_TIMEOUT_MS] ?? null]); + } + + /** A request timeout curl cannot take fails that request through its callback; nothing throws (D32). */ + public function testARequestTimeoutCurlCannotTakeFailsThatRequest(): void + { + $channel = new CurlHttpChannel(); + $errors = []; + $channel->post(new HttpRequest('http://127.0.0.1:9/v1/logs', [], '', connectTimeoutSeconds: 0.0), static function (HttpResponse $response) use (&$errors): void { + $errors[] = $response->error; + }); + + $channel->pump(); + + $this->assertSame(['InvalidArgumentException: A curl timeout must round to 1 through 2147483647 milliseconds, got 0.0 seconds'], $errors); + $this->assertSame(0, $channel->pending()); + } + + /** I1: `flush()` asks for the requests without the background ones (diagnostics). */ + public function testPendingCanLeaveOutTheBackgroundRequests(): void + { + $channel = new CurlHttpChannel(); + $channel->post(new HttpRequest('http://127.0.0.1:9/a', [], ''), static function (): void {}); + $channel->post(new HttpRequest('http://127.0.0.1:9/b', [], '', background: true), static function (): void {}); + $channel->post(new HttpRequest('http://127.0.0.1:9/c', [], '', background: true), static function (): void {}); + + $this->assertSame(3, $channel->pending()); + $this->assertSame(3, $channel->pending(true)); + $this->assertSame(1, $channel->pending(false)); + } + + /** + * A channel whose every add is refused: a pump fails each waiting request through its callback, and none is sent. + * + * @param list<\Throwable> $swallowed + */ + private function refusingChannel(array &$swallowed): CurlHttpChannel + { + return new CurlHttpChannel( + multiStatus: static fn(string $function, int $code): int => $function === 'curl_multi_add_handle' ? CURLM_INTERNAL_ERROR : $code, + onSwallowed: static function (\Throwable $error) use (&$swallowed): void { + $swallowed[] = $error; + }, + ); + } +} diff --git a/tests/Unit/Internal/Transport/CurlSupportTest.php b/tests/Unit/Internal/Transport/CurlSupportTest.php new file mode 100644 index 0000000..e25c8ad --- /dev/null +++ b/tests/Unit/Internal/Transport/CurlSupportTest.php @@ -0,0 +1,100 @@ + */ + public static function waits(): iterable + { + yield 'NAN' => [NAN, 0.0]; + yield 'negative' => [-1.0, 0.0]; + yield 'negative infinity' => [-INF, 0.0]; + yield 'zero' => [0.0, 0.0]; + yield 'inside' => [0.25, 0.25]; + yield 'the bound' => [1.0, 1.0]; + yield 'above' => [1.5, 1.0]; + yield 'INF' => [INF, 1.0]; + } + + /** PHP 8.4 changed `max(0.0, NAN)`: the bound is checked for NAN before `min()`/`max()` see it. */ + #[DataProvider('waits')] + public function testAWaitIsBoundedByZeroAndTheMaximum(float $seconds, float $expected): void + { + $this->assertSame($expected, CurlSupport::waitSeconds($seconds, 1.0)); + } + + /** @return iterable */ + public static function timeouts(): iterable + { + yield 'the default connect timeout' => [CurlTransport::CONNECT_TIMEOUT_SECONDS, 2000]; + yield 'the default total timeout' => [CurlTransport::TOTAL_TIMEOUT_SECONDS, 10_000]; + yield 'rounded up' => [1.2346, 1235]; + yield 'rounded down' => [1.2344, 1234]; + yield 'half a millisecond' => [0.0005, 1]; + yield 'the largest' => [CurlSupport::MAX_TIMEOUT_SECONDS, 2_147_483_647]; + } + + #[DataProvider('timeouts')] + public function testATimeoutIsWholeMilliseconds(float $seconds, int $milliseconds): void + { + $this->assertSame($milliseconds, CurlSupport::timeoutMilliseconds($seconds)); + } + + /** @return iterable */ + public static function badTimeouts(): iterable + { + yield 'NAN' => [NAN, 'NAN']; + yield 'INF' => [INF, 'INF']; + yield 'negative infinity' => [-INF, '-INF']; + yield 'negative' => [-1.0, '-1.0']; + yield 'zero (curl: no timeout)' => [0.0, '0.0']; + yield 'under half a millisecond (rounds to 0)' => [0.0004, '0.0004']; + yield 'above a 32-bit long of milliseconds' => [2_147_483.648, '2147483.648']; + } + + #[DataProvider('badTimeouts')] + public function testATimeoutThatCurlCannotTakeIsRejected(float $seconds, string $shown): void + { + $error = Thrown::by(\InvalidArgumentException::class, static fn(): int => CurlSupport::timeoutMilliseconds($seconds)); + + $this->assertSame('A curl timeout must round to 1 through 2147483647 milliseconds, got ' . $shown . ' seconds', $error->getMessage()); + } + + /** curl_setopt_array() stops at the first rejected option: the options that take the response come first. */ + public function testThePostOptionsStartWithTheOptionsThatTakeTheResponse(): void + { + $options = CurlSupport::postOptions([CURLOPT_RETURNTRANSFER => true], 'https://x.test/v1', '{}', ['A: 1'], 2000, 10_000); + + $this->assertSame(CURLOPT_RETURNTRANSFER, array_key_first($options)); + $this->assertSame([ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_URL => 'https://x.test/v1', + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => '{}', + CURLOPT_HTTPHEADER => ['A: 1', 'Expect:'], + CURLOPT_USERAGENT => CurlSupport::userAgent(), + CURLOPT_HEADER => false, + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, + CURLOPT_NOSIGNAL => true, + CURLOPT_CONNECTTIMEOUT_MS => 2000, + CURLOPT_TIMEOUT_MS => 10_000, + ], $options); + } + + public function testTheUserAgentNamesTheSdkAndPhp(): void + { + $this->assertSame('agentcat-php/' . SdkVersion::get() . ' php/' . PHP_VERSION, CurlSupport::userAgent()); + $this->assertSame(CurlSupport::userAgent(), CurlTransport::userAgent()); + } +} diff --git a/tests/Unit/OptionsTest.php b/tests/Unit/OptionsTest.php index 48937b8..d326403 100644 --- a/tests/Unit/OptionsTest.php +++ b/tests/Unit/OptionsTest.php @@ -5,11 +5,16 @@ namespace AgentCat\Tests\Unit; use AgentCat\Event; +use AgentCat\Exporters\DatadogExporter; +use AgentCat\Exporters\OtlpExporter; +use AgentCat\Exporters\PostHogExporter; +use AgentCat\Exporters\SentryExporter; use AgentCat\Identity; use AgentCat\Internal\Logging\Log; use AgentCat\Options; use AgentCat\Tests\Support\MemoryLogger; use AgentCat\ToolCall; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; @@ -107,6 +112,9 @@ public function testFromArrayAcceptsEveryOptionWithAValidValue(): void 'apiBaseUrl' => 'https://api.example.test', 'eventTags' => $eventTags, 'eventProperties' => $eventProperties, + 'exporters' => [], + 'disableDiagnostics' => true, + 'disableSignalHandlers' => true, ]); $this->assertFalse($options->enableReportMissing); @@ -121,6 +129,9 @@ public function testFromArrayAcceptsEveryOptionWithAValidValue(): void $this->assertSame('https://api.example.test', $options->apiBaseUrl); $this->assertSame($eventTags, $options->eventTags); $this->assertSame($eventProperties, $options->eventProperties); + $this->assertSame([], $options->exporters); + $this->assertTrue($options->disableDiagnostics); + $this->assertTrue($options->disableSignalHandlers); $this->assertSame([], $this->logger->messages()); } @@ -183,4 +194,154 @@ public function testFromArrayWithNoKeysEqualsDefaults(): void $this->assertEquals(new Options(), Options::fromArray([])); $this->assertSame([], $this->logger->messages()); } + + #[Group('ac-01-13')] + public function testTheTelemetryOptionsDefaultToNoneAndOn(): void + { + $options = new Options(); + + $this->assertNull($options->exporters); + $this->assertFalse($options->disableDiagnostics); + $this->assertFalse($options->disableSignalHandlers); + } + + public function testFromArrayBuildsEveryExporterTypeFromItsTypeScriptKeys(): void + { + $options = Options::fromArray([ + 'disableDiagnostics' => true, + 'exporters' => [ + 'otlp' => ['type' => 'otlp', 'endpoint' => 'http://localhost:4318', 'headers' => ['x-api-key' => 'k']], + 'dd' => ['type' => 'datadog', 'apiKey' => 'ddkey', 'site' => 'datadoghq.eu', 'service' => 'svc', 'env' => 'prod'], + 'sentry' => ['type' => 'sentry', 'dsn' => 'https://abc@o1.ingest.sentry.io/42', 'environment' => 'production', 'release' => '1.2.3', 'enableTracing' => true], + 'ph' => ['type' => 'posthog', 'apiKey' => 'phc_x', 'host' => 'https://eu.i.posthog.com', 'enableAITracing' => true], + 'typed' => new OtlpExporter('http://collector:4318'), + ], + ]); + + $this->assertTrue($options->disableDiagnostics); + $this->assertEquals([ + 'otlp' => new OtlpExporter('http://localhost:4318', ['x-api-key' => 'k']), + 'dd' => new DatadogExporter('ddkey', 'datadoghq.eu', 'svc', 'prod'), + 'sentry' => new SentryExporter('https://abc@o1.ingest.sentry.io/42', 'production', '1.2.3', true), + 'ph' => new PostHogExporter('phc_x', 'https://eu.i.posthog.com', true), + 'typed' => new OtlpExporter('http://collector:4318'), + ], $options->exporters); + $this->assertSame([], $this->logger->messages()); + } + + #[Group('ac-06-10')] + public function testFromArraySkipsAnUnknownTypeWithTheTypeScriptLine(): void + { + $options = Options::fromArray(['exporters' => ['a' => ['type' => 'otlp', 'endpoint' => 'http://h'], 'b' => ['type' => 'bogus'], 'c' => ['endpoint' => 'x']]]); + + $this->assertSame(['a'], array_keys($options->exporters ?? [])); + $this->assertSame(['Unknown exporter type: bogus', 'Unknown exporter type: undefined'], $this->logger->messages()); + } + + /** Review Focus 2: config files whose env() values are missing or mistyped. */ + public function testFromArraySkipsAnExporterWithMissingOrMistypedValues(): void + { + $options = Options::fromArray(['exporters' => [ + 'dd' => ['type' => 'datadog', 'apiKey' => null, 'site' => 'datadoghq.com', 'service' => 'svc'], + 'sentry' => ['type' => 'sentry', 'dsn' => 'https://abc@o1.ingest.sentry.io/42', 'enableTracing' => 'yes'], + 'otlp' => ['type' => 'otlp', 'endpoint' => 'http://h', 'headers' => ['x' => 1], 'timeout' => 5], + 'ph' => ['type' => 'posthog', 'apiKey' => 'phc_x', 'hots' => 'typo'], + 'str' => 'otlp', + ]]); + + $this->assertEquals(['ph' => new PostHogExporter('phc_x')], $options->exporters); + $this->assertSame([ + 'Failed to initialize exporter dd: InvalidArgumentException: exporter option "apiKey" must be string, got null', + 'Failed to initialize exporter sentry: InvalidArgumentException: exporter option "enableTracing" must be bool, got string', + 'WARN: Unknown AgentCat option "exporters.otlp.timeout" ignored', + 'Failed to initialize exporter otlp: InvalidArgumentException: exporter option "headers" must be array, got array', + 'WARN: Unknown AgentCat option "exporters.ph.hots" ignored', + 'Failed to initialize exporter str: InvalidArgumentException: exporter configuration must be an array, got string', + ], $this->logger->messages()); + } + + public function testFromArrayRejectsWrongTelemetryOptionTypes(): void + { + $options = Options::fromArray(['exporters' => 'otlp', 'disableSignalHandlers' => 1]); + + $this->assertNull($options->exporters); + $this->assertFalse($options->disableSignalHandlers); + $this->assertSame([ + 'WARN: AgentCat option "exporters" must be array or null, got string; the default is used', + 'WARN: AgentCat option "disableSignalHandlers" must be bool, got int; the default is used', + ], $this->logger->messages()); + } + + /** + * A4: the opt-out reads what a framework config file gives it, Laravel's `env()` strings included (`env()` turns + * only `true`/`false`/`(true)`/`(false)` into bools, so `1` in `.env` arrives as `'1'`). The words of + * `AGENTCAT_DISABLE_DIAGNOSTICS`, trimmed, in any case. Null is `env()` of an unset variable: not set, the default. + * + * @return iterable + */ + public static function diagnosticsSwitches(): iterable + { + yield 'true' => [true, true]; + yield 'false' => [false, false]; + yield "'true'" => ['true', true]; + yield "'1'" => ['1', true]; + yield "'yes'" => ['yes', true]; + yield "'on'" => ['on', true]; + yield "'TRUE'" => ['TRUE', true]; + yield "'Yes'" => ['Yes', true]; + yield "' on ' (trimmed)" => [' on ', true]; + yield 'int 1' => [1, true]; + yield "'false'" => ['false', false]; + yield "'0'" => ['0', false]; + yield "'no'" => ['no', false]; + yield "'off'" => ['off', false]; + yield "'OFF'" => ['OFF', false]; + yield "'' (an empty .env value)" => ['', false]; + yield "' ' (blank)" => [' ', false]; + yield 'int 0' => [0, false]; + yield 'null (env() of an unset variable)' => [null, false]; + } + + #[DataProvider('diagnosticsSwitches')] + public function testFromArrayReadsDisableDiagnosticsLikeTheEnvironmentVariable(mixed $value, bool $disabled): void + { + $this->assertSame($disabled, Options::fromArray(['disableDiagnostics' => $value])->disableDiagnostics); + $this->assertSame([], $this->logger->messages()); + } + + /** @return iterable */ + public static function unreadableDiagnosticsSwitches(): iterable + { + yield 'a typo' => ['ture', 'string']; + yield 'another word' => ['disabled', 'string']; + yield 'int 2' => [2, 'int']; + yield 'float 1.0' => [1.0, 'float']; + yield 'an array' => [[], 'array']; + } + + /** A4: a privacy opt-out fails closed: a value that is set but cannot be read logs one line and turns diagnostics off. */ + #[DataProvider('unreadableDiagnosticsSwitches')] + public function testFromArrayTurnsDiagnosticsOffForAValueItCannotRead(mixed $value, string $type): void + { + $this->assertTrue(Options::fromArray(['disableDiagnostics' => $value])->disableDiagnostics); + $this->assertSame( + ['WARN: AgentCat option "disableDiagnostics" must be bool, got ' . $type . '; the value is treated as true'], + $this->logger->messages(), + ); + } + + public function testFromArrayKeepsAnEmptyExporterListAndAnExplicitNull(): void + { + $this->assertSame([], Options::fromArray(['exporters' => []])->exporters); + $this->assertNull(Options::fromArray(['exporters' => null])->exporters); + $this->assertSame([], $this->logger->messages()); + } + + public function testFromArrayKeepsNumericExporterKeysAsIntegers(): void + { + $options = Options::fromArray(['exporters' => [['type' => 'otlp', 'endpoint' => 'x', 'bad' => 1]]]); + + $this->assertEquals([0 => new OtlpExporter('x')], $options->exporters); + $this->assertSame(['WARN: Unknown AgentCat option "exporters.0.bad" ignored'], $this->logger->messages()); + } } diff --git a/tests/Unit/Testing/AgentCatFakeTest.php b/tests/Unit/Testing/AgentCatFakeTest.php index 61e671a..6c18810 100644 --- a/tests/Unit/Testing/AgentCatFakeTest.php +++ b/tests/Unit/Testing/AgentCatFakeTest.php @@ -8,6 +8,7 @@ use AgentCat\Client; use AgentCat\CustomEvent; use AgentCat\Event; +use AgentCat\Exporters\OtlpExporter; use AgentCat\Identity; use AgentCat\Internal\ClientSeams; use AgentCat\Internal\Delivery\EventQueue; @@ -312,6 +313,27 @@ public function testLogsRunTheWaitingHooksFirst(): void $this->assertContains('eventTags callback error: RuntimeException: tags down', $fake->logs()); } + public function testExportersAreIgnoredByTheFake(): void + { + $fake = AgentCat::fake(); + $builder = TrackingFixture::builder(); + Tools::echo($builder); + AgentCat::track($builder, self::PROJECT, new Options(exporters: ['otlp' => new OtlpExporter('http://otlp.test')])); + $harness = new HttpHarness($builder->build()); + $harness->initialize(); + + $harness->callTool('echo', ['msg' => 'hi']); + + $fake->assertTrackedCount(1); + $events = $fake->events(); + $this->assertCount(1, $events); + $this->assertSame('mcp:tools/call', $events[0]->eventType); + $logs = $fake->logs(); + $this->assertContains('AgentCat setup complete | project ' . self::PROJECT . ' | tracing=true context=true reportMissing=true exporters=1', $logs); + // No `Initialized telemetry` assertion: the fake's queue already has a recorder sink, so install() skips it + // whatever the flag says. ClientExportersTest::testWithoutTheTelemetrySeamNoExportersAreSetUp covers the flag. + } + public function testTelemetryOnlyServersAreRecordedToo(): void { $fake = AgentCat::fake(); diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 4318307..d394152 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -15,6 +15,15 @@ putenv('HOME=' . $agentcatTestHome); $_SERVER['HOME'] = $_ENV['HOME'] = $agentcatTestHome; +// TS `src/tests/setup.ts`: no test sends diagnostics unless it clears this; subprocesses inherit it. A test that turns +// them on points AGENTCAT_DIAGNOSTICS_ENDPOINT at its own closed port (ruling P8); this one is the fallback, so a +// forgotten setting fails closed instead of reaching otel.agentcat.com. +putenv('AGENTCAT_DISABLE_DIAGNOSTICS=true'); +$_SERVER['AGENTCAT_DISABLE_DIAGNOSTICS'] = $_ENV['AGENTCAT_DISABLE_DIAGNOSTICS'] = 'true'; +$agentcatClosedPort = AgentCat\Tests\Integration\Support\MockCollector::closedPortUrl(); +putenv('AGENTCAT_DIAGNOSTICS_ENDPOINT=' . $agentcatClosedPort); +$_SERVER['AGENTCAT_DIAGNOSTICS_ENDPOINT'] = $_ENV['AGENTCAT_DIAGNOSTICS_ENDPOINT'] = $agentcatClosedPort; + register_shutdown_function(static function () use ($agentcatTestHome): void { $names = scandir($agentcatTestHome); foreach ($names === false ? [] : $names as $name) {