Skip to content

feat: exporters (OTLP, Datadog, Sentry, PostHog) and diagnostics - #5

Merged
naji247 merged 35 commits into
feat/plan-a-foundationfrom
feat/plan-d-exporters
Oct 22, 2026
Merged

naji247 merged 35 commits into
feat/plan-a-foundationfrom
feat/plan-d-exporters

Conversation

@naji247

@naji247 naji247 commented Oct 18, 2026

Copy link
Copy Markdown
Member

Fifth part of the PHP AgentCat SDK: exporters and diagnostics. Stacked on #4; review #1 to #4 first.

What is in it

  • Four exporters with the TypeScript key names: AgentCat\Exporters\OtlpExporter, DatadogExporter, SentryExporter and PostHogExporter. Pass them as new Options(exporters: ['name' => new OtlpExporter(...)]), or as arrays such as ['type' => 'otlp', ...] through Options::fromArray(). The first track() in a process that passes exporters sets them up for the process.
  • Payloads, IDs and log lines match the TypeScript SDK: OTLP traces, Datadog spans and logs, Sentry envelopes, PostHog batches, and the trace, span and UUIDv7 IDs. Each exporter runs once per API attempt.
  • A second non-blocking curl channel sends exporter and diagnostics requests next to the event transport. Both share one curl_multi engine. 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.
  • Diagnostics: every SDK log line also goes to AgentCat as OTLP logs, so we can find broken installs. One POST per FPM request at shutdown, with a 250 ms connect and 1 s total timeout. Opt-out rules match TypeScript: disableDiagnostics: true, or AGENTCAT_DISABLE_DIAGNOSTICS (the unprefixed DISABLE_DIAGNOSTICS also turns it off). AGENTCAT_DIAGNOSTICS_ENDPOINT and AGENTCAT_DIAGNOSTICS_TOKEN override the target.
  • disableSignalHandlers: true keeps AgentCat's SIGTERM and SIGINT handlers out of a process. It logs one warning when the handlers are already installed.
  • Signals during sends: a SIGTERM that lands while curl runs, or inside a curl callback, does not crash the process, and the drain still delivers the queued events. On PHP 8.3, an application signal handler that throws or exits right after a curl call does not corrupt memory (AgentCat keeps every curl call a statement of its own, which avoids a PHP 8.3 engine bug).
  • CI: Infection gets a 120 s timeout per mutant (the 10 s default skipped mutants), and the mutation job stops after 45 minutes.

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. The mcp-sdk-main job passes with 392 tests against mcp/sdk main at 82e19d1. 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.

naji247 and others added 30 commits October 17, 2026 20:02
… 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.
… 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.
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.
Base automatically changed from feat/plan-c2-public-api-and-e2e to feat/plan-a-foundation October 22, 2026 22:56
@naji247
naji247 merged commit 3db70ce into feat/plan-a-foundation Oct 22, 2026
11 of 12 checks passed
@naji247
naji247 deleted the feat/plan-d-exporters branch October 22, 2026 22:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants