Repository navigation
feat: exporters (OTLP, Datadog, Sentry, PostHog) and diagnostics - #5
Merged
Merged
Conversation
… of CurlTransport
… are rejected - CurlMulti runs the transfers of CurlTransport and CurlHttpChannel: record before the add, remove a refused handle, fail every transfer on a CURLM error, remove every finished handle. - A rejected curl option fails the attempt or request before the handle is added; the options that take the response come first; both constructors reject timeouts curl cannot take. - CurlSupport::waitSeconds() bounds every wait (NAN is 0 on PHP 8.4+ too), and CurlSupport::userAgent() replaces the two-way dependency. - CurlHttpChannel reports swallowed throwables to an optional test seam; its handle callbacks hold only the request's HttpTransfer. - Tests: no swallowed throwable on the normal paths, the oldest requests of a full queue are the dropped ones, the ten-transfer cap holds on every pass, and the timeout test holds a collector worker for 1 s.
…fter a failed exec
… budgets - EventQueue owns the side channel (new last constructor parameter, default CurlHttpChannel) and its companions: every pass of pump(), flush() and shutdown() ticks each companion, then pumps the channel; flush() and shutdown() are done only when the channel is idle too, and wait in 10 ms slices on each side while side requests are pending. - shutdown() with companions: when time is left after the delivery, each companion finishes, then one more pass to the same deadline drives what they posted and ticks no companion. Without companions it is unchanged. - New QueueCompanion, channel(), attach(), arm() (the delivery triggers without an event), hasWork() and exporterSink(); add() uses arm(). - ShutdownFlusher finishes the request when the queue has any work. - Tests: RecordingCompanion, channel waits in RecordingHttpChannel, and a slow export endpoint never holds the events back (Review Focus 1).
- With companions, the first shutdown pass stops when only the finish reserve is left (10 % of the budget, at most 0.5 s); then finish() runs and the last pass waits until the full deadline. A hung exporter or a slow API no longer keeps the companions from finishing. Without companions, shutdown() is unchanged. - finish() runs only after a first pass that really ran: not in a re-entrant shutdown() (the guard is still taken) and not when the clock could not be read (the deadline is unset). - The channel's wait slice is cut to the time left, so a drain never ends a slice past its deadline. - Docs: the pass after finish() ticks no companion. - Tests: RecordingHttpChannel can advance a test clock in its waits.
…gnostics and disableSignalHandlers options
Document the switch's timing and first-wins rule, and cover the SIGINT handler, async-signal mode and shutdown-function delivery.
A diagnostics batch is now a background request on the side channel: flush() neither counts nor waits for it, and shutdown() still sends it inside its budget (D47). Before, a flush pass that posted a batch then waited for AgentCat's collector, up to the 2 s connect or 10 s transfer timeout, and could return false with every event and export delivered.
A signal handler runs between two opcodes, so also inside a write or header callback of curl_multi_exec(). A SIGTERM drain there re-entered the same multi handle: libcurl refused the calls, the failure branch dropped the handles, and PHP crashed when the callback returned. CurlMulti now marks the time inside curl_multi_exec(). Meanwhile run() and select() return at once, CurlHttpChannel starts no transfer (the request waits for the next pump) and reports nothing pending, so the drain delivers the events on the transport's own handle and returns. The mark is cleared when its holder is released, which also happens when exit() in such a handler unwinds the frame (finally does not run).
"Invalid Sentry DSN: <dsn>" printed the public key, and for a legacy https://public:secret@host/1 DSN the secret, into the log and so into diagnostics. The line now shows https://***@host/1: the scheme, host, port and path stay. TypeScript prints the whole DSN; this is a deliberate divergence.
Options::fromArray() took only a bool, so 'disableDiagnostics' =>
env('AGENTCAT_DISABLE_DIAGNOSTICS') with 1 in .env (the string '1')
logged a warning and left diagnostics on. It now reads int 1 and 0 and
the words of DISABLE_DIAGNOSTICS (true, 1, yes, on; false, 0, no, off,
blank; trimmed, any case). Any other value logs the usual warning and
turns diagnostics off: a privacy opt-out fails closed.
With a black-holed exporter, a long-running worker logged one WARNING line per dropped request into the customer's PSR-3 channel and into diagnostics. The channel now counts the drops and logs at most one line per pass: "WARN: AgentCat telemetry export queue full, dropped the oldest <n> request(s)".
Both buffers were capped by count only: 1 000 waiting exporter requests (PostHog bodies carry parameters and responses) and 1 000 diagnostics records (failure lines quote up to 64 KiB). During an exporter outage a long-running worker could pass its memory_limit. The waiting requests now hold at most 16 MiB of bodies (the oldest go, with the same WARN line), the diagnostics buffer at most 1 MiB of record bodies (the oldest go silently). The newest request or line is always kept.
The text named only "the lines of agentcat.log", which is wrong when a PSR-3 logger is adopted. It now lists every SDK log line, the quoted answers of failed exporters, and the resource attributes of each batch.
A hanging mutant holds a thread for the 120 s Infection timeout; the job timeout turns a pathological run into a clear failure instead of the 6 h default.
D50: diagnostics read AGENTCAT_DISABLE_DIAGNOSTICS, AGENTCAT_DIAGNOSTICS_ENDPOINT and AGENTCAT_DIAGNOSTICS_TOKEN. The unprefixed DISABLE_DIAGNOSTICS stays only as a fallback, read while the prefixed one is blank, and it can only turn diagnostics off. The unprefixed DIAGNOSTICS_ENDPOINT and DIAGNOSTICS_TOKEN are never read, so an application's own variables neither redirect AgentCat's batches nor send their secret to AgentCat. A divergence from TypeScript. The test bootstrap sets AGENTCAT_DISABLE_DIAGNOSTICS=true and points AGENTCAT_DIAGNOSTICS_ENDPOINT at a closed port, so a test that forgets its own setting fails closed; tests and fixtures use the new names.
…s as true env() of an unset variable gives null: Options::fromArray() now reads it as not set (the default, diagnostics on, no warning). Only a value that is set but cannot be read still fails closed, and its line now says so: "WARN: AgentCat option "disableDiagnostics" must be bool, got <type>; the value is treated as true" instead of "the default is used".
D49: with a blocked otel.agentcat.com, a per-request runtime held a worker after the response for the 2 s connect timeout, and up to 10 s when the collector accepted and never answered. An HttpRequest can now carry its own timeouts; diagnostics batches use 250 ms and 1 s, and exporters keep the channel's 2 s and 10 s. A request timeout curl cannot take fails that request through its callback.
Controller ruling on D50: when AGENTCAT_DISABLE_DIAGNOSTICS and DISABLE_DIAGNOSTICS disagree, off wins. DISABLE_DIAGNOSTICS=true now turns diagnostics off also when AGENTCAT_DISABLE_DIAGNOSTICS=false, and a false value of either name never turns them on over the other's true: a privacy opt-out fails closed.
The A2 guard was set around curl_multi_exec() in the shared engine. PHP runs a signal that arrived during that call right after it returns, while the flag was still set, so a SIGTERM drain there delivered nothing: the events and exports waited out the 5 s budget and were lost with the default disposition (N1). Controller ruling: CurlMulti has no guard. A curl_multi_* call that libcurl answers with CURLM_RECURSIVE_API_CALL leaves every handle and transfer as it is; a refused add waits for the next run(). The side channel is busy only while one of its own write or header callbacks runs: each sets a flag first and clears it as its last statement (finally returns through a jump where PHP runs pending signals). While busy it reports nothing pending and does no curl work. PHP 8.4+ also runs signals at a callback's entry, so a refused run() marks the channel busy too, and a flag left set by exit() is cleared once no curl_multi_exec() is on the stack. OnRelease is gone. Tests: a real SIGTERM right after curl_multi_exec() returns, for the transport and the channel, must deliver the event and its export; the in-callback segfault scenario and the exit case stay. Timing bounds keep about 1 s of slack (N3), and the disableDiagnostics PHPDoc says the 1 s cap is per diagnostics request (N4).
PHP runs a signal that arrived during a curl_* call right after the call returns. On PHP 8.3, a handler that throws or calls exit() there, while an outer call is still being set up, corrupts the frame: the process segfaulted, hung or died with an out-of-memory error, and the queued events were lost (I1). curl_multi_exec() and curl_multi_add_handle() were nested in the multiStatus seam's call, and curl_multi_strerror() in sprintf(). Each is now a statement of its own. The header callback now sets its busy flag before the test seam, like the write callback: PHP 8.3 runs pending signals at forward jumps too, so the seam's check was a window before the flag (M1). The seam gets the flag, so a test can still stand for PHP 8.4+'s callback entry, and the comments say where each PHP version runs signals.
kashishhora
approved these changes
Oct 22, 2026
Base automatically changed from
feat/plan-c2-public-api-and-e2e
to
feat/plan-a-foundation
October 22, 2026 22:56
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fifth part of the PHP AgentCat SDK: exporters and diagnostics. Stacked on #4; review #1 to #4 first.
What is in it
AgentCat\Exporters\OtlpExporter,DatadogExporter,SentryExporterandPostHogExporter. Pass them asnew Options(exporters: ['name' => new OtlpExporter(...)]), or as arrays such as['type' => 'otlp', ...]throughOptions::fromArray(). The firsttrack()in a process that passes exporters sets them up for the process.curl_multiengine. The event queue pumps the channel while it sends and drains it inside the same 5 s shutdown budget, with a reserve at the end for these requests. Waiting requests are capped by bytes.disableDiagnostics: true, orAGENTCAT_DISABLE_DIAGNOSTICS(the unprefixedDISABLE_DIAGNOSTICSalso turns it off).AGENTCAT_DIAGNOSTICS_ENDPOINTandAGENTCAT_DIAGNOSTICS_TOKENoverride the target.disableSignalHandlers: truekeeps AgentCat's SIGTERM and SIGINT handlers out of a process. It logs one warning when the handlers are already installed.Testing
2353 tests pass (
composer check; two expected skips). The covered mutation score on changed lines is 98.55 %; the CI mutation job runs the full set. Themcp-sdk-mainjob passes with 392 tests againstmcp/sdkmain at82e19d1. PHP 8.5 with the highest and the lowest dependency sets passed locally in Docker (both PHPStan configs and PHPUnit). Real-signal tests cover a SIGTERM during a transfer, a signal inside a curl callback, and an application handler that throws right after curl returns; they ran on PHP 8.3, 8.4 and 8.5 with pcntl.