Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
5a72da5
feat: derive TypeScript trace, span and UUIDv7 IDs for exporters
naji247 Oct 17, 2026
28baa48
refactor: share the ext-curl check, POST options and curl error texts…
naji247 Oct 17, 2026
6aac54a
feat: add a non-blocking curl side channel for exporter and diagnosti…
naji247 Oct 17, 2026
927608f
fix: share one curl_multi engine and fail requests whose curl options…
naji247 Oct 17, 2026
0965bce
test: pin the first transfer step of start() and the handle removal a…
naji247 Oct 17, 2026
b726029
ci: give Infection a 120 s per-mutant timeout so no mutant is skipped
naji247 Oct 17, 2026
adc88c2
feat: let the event queue pump and drain exporter requests within its…
naji247 Oct 17, 2026
95abb7f
fix: keep the end of the shutdown budget for queue companions
naji247 Oct 17, 2026
077fa39
feat: add typed exporter configurations and the exporters, disableDia…
naji247 Oct 17, 2026
34bee7a
feat: build TypeScript-identical OTLP and Datadog export payloads
naji247 Oct 17, 2026
61387b2
feat: build TypeScript-identical Sentry envelopes and PostHog batches
naji247 Oct 17, 2026
201e3f9
fix: match JavaScript ASCII classes in the Sentry DSN pattern
Oct 17, 2026
144c2cc
feat: send events to OTLP, Datadog, Sentry and PostHog with TypeScrip…
naji247 Oct 17, 2026
9ae3aef
feat: set up exporters in track() first-wins with the TypeScript beacons
naji247 Oct 17, 2026
bd1810d
test: pin the default client's exporter setup and drop an unobservabl…
naji247 Oct 17, 2026
ab8c75d
feat: tee every SDK log line to AgentCat diagnostics with TypeScript …
naji247 Oct 18, 2026
7d5f92f
fix: keep diagnostics on hardened hosts and warn only when they started
naji247 Oct 18, 2026
b3ef409
feat: set diagnostics up in track() and flush them at the end of each…
naji247 Oct 18, 2026
3dcdb28
test: count the stdio diagnostics POSTs and tie each HTTP request to …
naji247 Oct 18, 2026
e9ef275
feat: add disableSignalHandlers to keep AgentCat's signal handlers ou…
Oct 18, 2026
b4b994d
fix: log the ignored disableSignalHandlers warning once per process
Oct 18, 2026
8007d41
fix: keep AgentCat's diagnostics requests out of flush()
naji247 Oct 18, 2026
c8a7609
fix: leave the side channel alone when a signal handler re-enters it
naji247 Oct 18, 2026
acc6ac3
fix: mask the user-info of an invalid Sentry DSN in its log line
naji247 Oct 18, 2026
39a866f
fix: read disableDiagnostics from config arrays like DISABLE_DIAGNOSTICS
naji247 Oct 18, 2026
60a6c9f
fix: log the full export queue once per pass, with the number dropped
naji247 Oct 18, 2026
9e29b3c
fix: cap the waiting side requests and the diagnostics buffer by bytes
naji247 Oct 18, 2026
922ec2c
docs: say exactly what diagnostics send in the disableDiagnostics PHPDoc
naji247 Oct 18, 2026
1a677c6
ci: stop the mutation job after 45 minutes
naji247 Oct 18, 2026
9100c29
fix: read the diagnostics variables with the AGENTCAT_ prefix
naji247 Oct 18, 2026
81e33b8
fix: treat a null disableDiagnostics as not set and say when it count…
naji247 Oct 18, 2026
d775c1d
fix: give diagnostics requests a 250 ms connect and 1 s total timeout
naji247 Oct 18, 2026
7871965
fix: let an off value of either diagnostics variable win
naji247 Oct 18, 2026
e760eeb
fix: keep the curl re-entrancy guard out of the event transport
naji247 Oct 18, 2026
b6c836b
fix: never nest a curl call in another call's arguments
naji247 Oct 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions infection.json5
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
5 changes: 3 additions & 2 deletions src/AgentCat.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
27 changes: 22 additions & 5 deletions src/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand All @@ -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.
Expand All @@ -50,6 +54,7 @@
$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;
}

/**
Expand Down Expand Up @@ -82,6 +87,13 @@
}
$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()) {

Check warning on line 94 in src/Client.php

View workflow job for this annotation

GitHub Actions / Mutation testing

Escaped Mutant for Mutator "LogicalAndSingleSubExprNegation": @@ @@ // 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()) { + 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)) {
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));
Expand All @@ -106,20 +118,24 @@

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;
Expand Down Expand Up @@ -228,8 +244,9 @@
*
* @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
{
Expand Down
25 changes: 25 additions & 0 deletions src/Exporters/DatadogExporter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace AgentCat\Exporters;

/**
* Sends each event to Datadog's log and metric intake (not APM traces). Use it in
* `Options(exporters: ['datadog' => 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,
) {}
}
21 changes: 21 additions & 0 deletions src/Exporters/OtlpExporter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace AgentCat\Exporters;

/**
* Sends each event as one span to an OTLP/HTTP JSON collector. Use it in
* `Options(exporters: ['otlp' => 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<string, string> $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 = [],
) {}
}
23 changes: 23 additions & 0 deletions src/Exporters/PostHogExporter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace AgentCat\Exporters;

/**
* Sends each event to PostHog's batch API as a capture event, plus `$exception` for failed calls. Use it in
* `Options(exporters: ['posthog' => 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,
) {}
}
25 changes: 25 additions & 0 deletions src/Exporters/SentryExporter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace AgentCat\Exporters;

/**
* Sends each event to Sentry as a log, optionally a transaction, and an error event for failed calls. Use it in
* `Options(exporters: ['sentry' => new SentryExporter(dsn: '…')])`.
*/
final readonly class SentryExporter
{
/**
* @param string $dsn Your project's DSN, `https://<public key>@<host>/<project ID>`. 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,
) {}
}
12 changes: 8 additions & 4 deletions src/Internal/ClientSeams.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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);
}
}
3 changes: 3 additions & 0 deletions src/Internal/Constants.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
Loading
Loading