From 5a72da5a83aa89ffe0804b6f0b9fa9be58cfa52c Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Sat, 17 Oct 2026 20:02:58 +0000 Subject: [PATCH 01/35] feat: derive TypeScript trace, span and UUIDv7 IDs for exporters --- src/Internal/Id/Ksuid.php | 2 +- src/Internal/Id/UuidV7.php | 77 +++++++++++ src/Internal/Telemetry/TraceContext.php | 43 +++++++ tests/Golden/ExporterIdsGoldenTest.php | 34 +++++ tests/Support/ExporterGolden.php | 46 +++++++ tests/Unit/Internal/Id/UuidV7Test.php | 120 ++++++++++++++++++ .../Internal/Telemetry/TraceContextTest.php | 69 ++++++++++ 7 files changed, 390 insertions(+), 1 deletion(-) create mode 100644 src/Internal/Id/UuidV7.php create mode 100644 src/Internal/Telemetry/TraceContext.php create mode 100644 tests/Golden/ExporterIdsGoldenTest.php create mode 100644 tests/Support/ExporterGolden.php create mode 100644 tests/Unit/Internal/Id/UuidV7Test.php create mode 100644 tests/Unit/Internal/Telemetry/TraceContextTest.php diff --git a/src/Internal/Id/Ksuid.php b/src/Internal/Id/Ksuid.php index cfc5445..897c96e 100644 --- a/src/Internal/Id/Ksuid.php +++ b/src/Internal/Id/Ksuid.php @@ -12,7 +12,7 @@ final class Ksuid { private const string ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; - private const int EPOCH_MS = 1_400_000_000_000; + public const int EPOCH_MS = 1_400_000_000_000; private const int MAX_SECONDS = 4_294_967_295; private const int ENCODED_LENGTH = 27; private const int PAYLOAD_LENGTH = 16; diff --git a/src/Internal/Id/UuidV7.php b/src/Internal/Id/UuidV7.php new file mode 100644 index 0000000..d7e9caa --- /dev/null +++ b/src/Internal/Id/UuidV7.php @@ -0,0 +1,77 @@ + 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/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 @@ +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/Support/ExporterGolden.php b/tests/Support/ExporterGolden.php new file mode 100644 index 0000000..eeb5ff2 --- /dev/null +++ b/tests/Support/ExporterGolden.php @@ -0,0 +1,46 @@ + 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'); + } +} 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/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')); + } +} From 28baa487b9873f2bbab77262f9a7639cfe45eafb Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Sat, 17 Oct 2026 20:37:49 +0000 Subject: [PATCH 02/35] refactor: share the ext-curl check, POST options and curl error texts of CurlTransport --- src/Internal/Transport/CurlSupport.php | 100 +++++++++++++++++++++++ src/Internal/Transport/CurlTransport.php | 63 +++----------- 2 files changed, 111 insertions(+), 52 deletions(-) create mode 100644 src/Internal/Transport/CurlSupport.php diff --git a/src/Internal/Transport/CurlSupport.php b/src/Internal/Transport/CurlSupport.php new file mode 100644 index 0000000..713c2a5 --- /dev/null +++ b/src/Internal/Transport/CurlSupport.php @@ -0,0 +1,100 @@ +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; + } + + /** + * The options of one `POST`: `$headers` (`Name: value` lines) and the SDK User-Agent, http and https only, no + * redirects, no signals, connect and total timeouts rounded to milliseconds. The response headers stay out of + * the body. + * + * @param list $headers + * + * @return array + */ + public static function postOptions(string $url, string $body, array $headers, float $connectTimeoutSeconds, float $totalTimeoutSeconds): array + { + return [ + 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 => CurlTransport::userAgent(), + CURLOPT_HEADER => false, + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, + CURLOPT_NOSIGNAL => true, + CURLOPT_CONNECTTIMEOUT_MS => (int) round($connectTimeoutSeconds * 1000), + CURLOPT_TIMEOUT_MS => (int) round($totalTimeoutSeconds * 1000), + ]; + } + + /** `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 + { + return sprintf('cURL multi error %d: %s', $code, curl_multi_strerror($code)); + } +} diff --git a/src/Internal/Transport/CurlTransport.php b/src/Internal/Transport/CurlTransport.php index 6f2e904..7f57730 100644 --- a/src/Internal/Transport/CurlTransport.php +++ b/src/Internal/Transport/CurlTransport.php @@ -31,21 +31,11 @@ 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', - ]; - /** 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; + /** The ext-curl check, made at the first delivery. */ + private readonly CurlSupport $curl; /** @var \Closure(): float */ private readonly \Closure $clock; @@ -78,7 +68,7 @@ public function __construct( ) { $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); } public static function userAgent(): string @@ -89,7 +79,7 @@ public static function userAgent(): string #[\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 +92,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 @@ -197,35 +187,10 @@ 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), - ]; - } - - /** 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))); - } - } + $headers = ['Content-Type: application/json', 'Accept: application/json']; - return $this->usable; + return CurlSupport::postOptions($delivery->url, $delivery->body, $headers, $this->connectTimeoutSeconds, $this->totalTimeoutSeconds) + + [CURLOPT_RETURNTRANSFER => true]; } private function multi(): \CurlMultiHandle @@ -280,7 +245,7 @@ private function launch(): void 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)); + $this->failed($next['delivery'], $next['attempt'], CurlSupport::multiError($code)); } } } @@ -295,7 +260,7 @@ private function collect(): void $this->active = []; foreach ($active as $entry) { curl_multi_remove_handle($this->multi(), $entry['handle']); - $this->failed($entry['delivery'], $entry['attempt'], $this->multiError($code)); + $this->failed($entry['delivery'], $entry['attempt'], CurlSupport::multiError($code)); } } while (true) { @@ -317,8 +282,7 @@ private function collect(): void 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 +295,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)); From 6aac54a9bb1becc8a8b11463a11317fe31e898bb Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Sat, 17 Oct 2026 20:38:12 +0000 Subject: [PATCH 03/35] feat: add a non-blocking curl side channel for exporter and diagnostics requests --- src/Internal/Transport/CurlHttpChannel.php | 235 +++++++++ src/Internal/Transport/HttpChannel.php | 31 ++ src/Internal/Transport/HttpRequest.php | 20 + src/Internal/Transport/HttpResponse.php | 38 ++ tests/Integration/Support/MockCollector.php | 5 +- tests/Integration/Support/router.php | 7 +- .../Transport/CurlHttpChannelTest.php | 470 ++++++++++++++++++ tests/Support/RecordingHttpChannel.php | 61 +++ .../Transport/CurlHttpChannelUnitTest.php | 185 +++++++ 9 files changed, 1049 insertions(+), 3 deletions(-) create mode 100644 src/Internal/Transport/CurlHttpChannel.php create mode 100644 src/Internal/Transport/HttpChannel.php create mode 100644 src/Internal/Transport/HttpRequest.php create mode 100644 src/Internal/Transport/HttpResponse.php create mode 100644 tests/Integration/Transport/CurlHttpChannelTest.php create mode 100644 tests/Support/RecordingHttpChannel.php create mode 100644 tests/Unit/Internal/Transport/CurlHttpChannelUnitTest.php diff --git a/src/Internal/Transport/CurlHttpChannel.php b/src/Internal/Transport/CurlHttpChannel.php new file mode 100644 index 0000000..378167d --- /dev/null +++ b/src/Internal/Transport/CurlHttpChannel.php @@ -0,0 +1,235 @@ + */ + private array $waiting = []; + + /** @var array transfers in `$multi`, keyed by `spl_object_id($handle)` */ + private array $active = []; + + /** + * @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 + */ + public function __construct( + private readonly float $connectTimeoutSeconds = CurlTransport::CONNECT_TIMEOUT_SECONDS, + private readonly float $totalTimeoutSeconds = CurlTransport::TOTAL_TIMEOUT_SECONDS, + ?\Closure $functionExists = null, + ?\Closure $multiStatus = null, + ) { + $this->curl = new CurlSupport(self::NO_CURL, $functionExists); + $this->multiStatus = $multiStatus ?? static fn(string $function, int $code): int => $code; + } + + #[\Override] + public function post(HttpRequest $request, \Closure $onDone): void + { + if (!$this->curl->usable()) { + return; // dropped: nothing can send it + } + if (count($this->waiting) >= self::MAX_WAITING) { + array_shift($this->waiting); + Log::write(self::FULL); + } + $this->waiting[] = ['request' => $request, 'onDone' => $onDone]; + } + + #[\Override] + public function pump(): void + { + if ($this->waiting === [] && $this->active === []) { + return; // nothing posted (or nothing can be): no curl call + } + $this->guarded(function (): void { + $this->launch(); + $this->collect(); + $this->launch(); // requests that callbacks posted (Sentry's sequence) start in this pass + }); + } + + #[\Override] + public function pending(): int + { + return count($this->waiting) + count($this->active); + } + + #[\Override] + public function waitForActivity(float $seconds): void + { + if ($this->active === []) { + return; // a waiting request starts at the next pump() + } + $timeout = is_nan($seconds) ? 0.0 : max(0.0, min($seconds, self::MAX_WAIT_SECONDS)); + $this->guarded(function () use ($timeout): void { + if (curl_multi_select($this->multi(), $timeout) === -1) { + usleep(1000); + } + }); + } + + /** Transfers in the multi handle (tests). */ + public function inTransfer(): int + { + return count($this->active); + } + + private function multi(): \CurlMultiHandle + { + return $this->multi ??= curl_multi_init(); + } + + /** @param \Closure(): void $work */ + private function guarded(\Closure $work): void + { + set_error_handler(static fn(): bool => true); + + try { + $work(); + } catch (\Throwable) { + // a side request must never reach the host (D32) + } + restore_error_handler(); + } + + /** Starts waiting requests 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->waiting !== []) { + $next = array_shift($this->waiting); + $handle = curl_init(); + if ($handle === false) { + $this->finish($next['onDone'], HttpResponse::failed('cURL error: curl_init() failed')); + + continue; + } + $id = spl_object_id($handle); + + try { + curl_setopt_array($handle, $this->options($next['request'], $id)); + } catch (\Throwable $error) { + // a URL with a NUL byte (ValueError): the request fails, the others go on + $this->finish($next['onDone'], HttpResponse::failed(ErrorText::of($error))); + + continue; + } + // Recorded before the add: a signal drain that runs right after the add must see this transfer. + $this->active[$id] = ['handle' => $handle, 'onDone' => $next['onDone'], 'reason' => '', 'body' => '']; + $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->finish($next['onDone'], HttpResponse::failed(CurlSupport::multiError($code))); + } + } + } + + /** Runs the transfers, then reads every transfer that this run finished and runs its callback. */ + 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 no flush waits for them forever. + $active = $this->active; + $this->active = []; + foreach ($active as $entry) { + curl_multi_remove_handle($this->multi(), $entry['handle']); + $this->finish($entry['onDone'], HttpResponse::failed(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; + } + $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->finish($entry['onDone'], $info['result'] === CURLE_OK + ? new HttpResponse(curl_getinfo($info['handle'], CURLINFO_RESPONSE_CODE), $entry['reason'], $entry['body']) + : HttpResponse::failed(CurlSupport::transferError($info['handle'], $info['result']))); + } + } + } + + /** @param \Closure(HttpResponse): void $onDone */ + private function finish(\Closure $onDone, HttpResponse $response): void + { + try { + $onDone($response); + } catch (\Throwable) { + // our callbacks log and never throw; a broken one must not stop the others + } + } + + /** @return array */ + private function options(HttpRequest $request, int $id): array + { + $headers = []; + foreach ($request->headers as $name => $value) { + $headers[] = $name . ': ' . $value; + } + + return CurlSupport::postOptions($request->url, $request->body, $headers, $this->connectTimeoutSeconds, $this->totalTimeoutSeconds) + [ + CURLOPT_HEADERFUNCTION => function (\CurlHandle $handle, string $line) use ($id): int { + if (isset($this->active[$id]) && preg_match(self::STATUS_LINE, $line, $match) === 1) { + $this->active[$id]['reason'] = $match[1] ?? ''; + } + + return strlen($line); + }, + CURLOPT_WRITEFUNCTION => function (\CurlHandle $handle, string $data) use ($id): int { + if (isset($this->active[$id])) { + // Up to MAX_BODY_BYTES are kept; the rest is read and dropped. + $this->active[$id]['body'] .= substr($data, 0, self::MAX_BODY_BYTES - strlen($this->active[$id]['body'])); + } + + return strlen($data); // a shorter return value would abort the transfer + }, + ]; + } +} diff --git a/src/Internal/Transport/HttpChannel.php b/src/Internal/Transport/HttpChannel.php new file mode 100644 index 0000000..bb9ce3a --- /dev/null +++ b/src/Internal/Transport/HttpChannel.php @@ -0,0 +1,31 @@ + $headers name => value, sent in this order */ + public function __construct( + public string $url, + public array $headers, + public string $body, + ) {} +} 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/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..60c49ef --- /dev/null +++ b/tests/Integration/Transport/CurlHttpChannelTest.php @@ -0,0 +1,470 @@ +stop(); + self::$collector = null; + } + + #[\Override] + protected function setUp(): void + { + $this->logger = new MemoryLogger(); + Log::setLogger($this->logger); + } + + #[\Override] + protected function tearDown(): void + { + Log::reset(); + } + + #[MaximumDuration(3000)] + public function testAPostCarriesItsHeadersAndBodyAndReportsTheAnswer(): void + { + $url = $this->collector()->scenario([['status' => 404, 'body' => 'nope']]); + $channel = new CurlHttpChannel(); + $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' => 2000]); + $channel = new CurlHttpChannel(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 = new CurlHttpChannel(); + $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 = new CurlHttpChannel(); + $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 = new CurlHttpChannel(); + $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 no handle (and no reference cycle) behind. */ + #[MaximumDuration(3000)] + public function testAFinishedTransferLeavesNothingBehind(): void + { + $url = $this->collector()->scenario(); + $channel = new CurlHttpChannel(); + $channel->post(new HttpRequest($url . '/a', [], '{}'), static function (): void {}); + $this->assertTrue($this->drain($channel, 2.0)); + $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 = new CurlHttpChannel(); + $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)); + } + + /** 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. */ + #[MaximumDuration(3000)] + public function testAtMostTenTransfersRunAtOnce(): void + { + $url = $this->collector()->scenario([], ['status' => 200, 'delayMs' => 200]); + $channel = new CurlHttpChannel(); + 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()); + $this->assertTrue($this->drain($channel, 3.0)); + } + + /** 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 = new CurlHttpChannel( + 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 = new CurlHttpChannel(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 = []; + $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); + + $channel->pump(); + $this->assertSame(0, $channel->pending()); + $channel->post(new HttpRequest($url . '/c', [], '{}'), $record); + $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. + $requests = $this->collector()->requests($url); + $this->assertCount(1, $requests); + $this->assertSame('/c', $requests[0]->path); + } + + #[MaximumDuration(3000)] + public function testAFailedAddFailsThatRequestOnly(): void + { + $url = $this->collector()->scenario(); + $adds = new \ArrayObject(); + $channel = new CurlHttpChannel(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 = []; + $record = static function (HttpResponse $response) use (&$responses): void { + $responses[] = $response; + }; + $channel->post(new HttpRequest($url . '/refused', [], '{}'), $record); + $channel->post(new HttpRequest($url . '/sent', [], '{}'), $record); + + $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 it holds the channel no longer. + $requests = $this->collector()->requests($url); + $this->assertCount(1, $requests); + $this->assertSame('/sent', $requests[0]->path); + $weak = \WeakReference::create($channel); + unset($channel); + $this->assertNull($weak->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 = new CurlHttpChannel(); + $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)); + } + + #[MaximumDuration(3000)] + public function testAThrowingCallbackDoesNotStopTheOthers(): void + { + $url = $this->collector()->scenario(); + $channel = new CurlHttpChannel(); + $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()); + } + + /** 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 = new CurlHttpChannel(); + $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.3 computes a + * 0.0 bound and PHP 8.4+ a NAN one (`max(0.0, NAN)`), which curl_multi_select() rejects with a ValueError. + */ + #[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 = new CurlHttpChannel(); + $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]); + } + + 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/Support/RecordingHttpChannel.php b/tests/Support/RecordingHttpChannel.php new file mode 100644 index 0000000..ca59221 --- /dev/null +++ b/tests/Support/RecordingHttpChannel.php @@ -0,0 +1,61 @@ + */ + public array $requests = []; + + /** @var list */ + private array $held = []; + + private readonly HttpResponse $default; + + /** @param list $responses the answers to requests 0, 1, 2, … */ + public function __construct( + private readonly array $responses = [], + ?HttpResponse $default = null, + public bool $complete = true, + ) { + $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]; + } + + #[\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(): int + { + return count($this->held); + } + + #[\Override] + public function waitForActivity(float $seconds): void {} + + /** @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/Unit/Internal/Transport/CurlHttpChannelUnitTest.php b/tests/Unit/Internal/Transport/CurlHttpChannelUnitTest.php new file mode 100644 index 0000000..d1bc06c --- /dev/null +++ b/tests/Unit/Internal/Transport/CurlHttpChannelUnitTest.php @@ -0,0 +1,185 @@ +logger = new MemoryLogger(); + Log::setLogger($this->logger); + } + + #[\Override] + protected function tearDown(): void + { + Log::reset(); + } + + public function testAFullQueueDropsTheOldestRequestWithOneLineEach(): void + { + $channel = new CurlHttpChannel(); + + for ($i = 0; $i < CurlHttpChannel::MAX_WAITING + 2; ++$i) { + $channel->post(new HttpRequest('http://127.0.0.1:9/r' . $i, [], '{}'), static function (): void {}); + } + + $this->assertSame(1000, $channel->pending()); + $this->assertSame(0, $channel->inTransfer()); + $this->assertSame([self::FULL, self::FULL], $this->logger->messages()); + } + + 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); + } +} From 927608f13c0e0cfbbaac79175c6d089e8c588980 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Sat, 17 Oct 2026 21:18:04 +0000 Subject: [PATCH 04/35] fix: share one curl_multi engine and fail requests whose curl options 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. --- src/Internal/Transport/CurlHttpChannel.php | 168 ++++++++---------- src/Internal/Transport/CurlMulti.php | 121 +++++++++++++ src/Internal/Transport/CurlSupport.php | 60 +++++-- src/Internal/Transport/CurlTransport.php | 105 ++++------- src/Internal/Transport/HttpTransfer.php | 23 +++ .../Transport/CurlHttpChannelTest.php | 165 +++++++++++++---- .../Transport/CurlTransportTest.php | 48 +++++ .../Transport/CurlHttpChannelUnitTest.php | 39 +++- .../Internal/Transport/CurlSupportTest.php | 100 +++++++++++ 9 files changed, 620 insertions(+), 209 deletions(-) create mode 100644 src/Internal/Transport/CurlMulti.php create mode 100644 src/Internal/Transport/HttpTransfer.php create mode 100644 tests/Unit/Internal/Transport/CurlSupportTest.php diff --git a/src/Internal/Transport/CurlHttpChannel.php b/src/Internal/Transport/CurlHttpChannel.php index 378167d..dd877d2 100644 --- a/src/Internal/Transport/CurlHttpChannel.php +++ b/src/Internal/Transport/CurlHttpChannel.php @@ -10,7 +10,8 @@ /** * The second curl_multi handle of a queue: exporter and diagnostics requests, no retries (TypeScript sends each once), * connect and total timeouts, http/https only, no redirects. `pump()` never blocks. PHP warnings are suppressed and - * every throwable is swallowed (D32): a failed request reaches only its callback. + * every throwable is swallowed (D32; tests see them through `$onSwallowed`): a failed request reaches only its + * callback. `CurlMulti` runs the transfers. * * @internal */ @@ -32,34 +33,38 @@ final class CurlHttpChannel implements HttpChannel /** The reason phrase of a status line; the last status line of a response wins. */ private const string STATUS_LINE = '~\AHTTP/\S+\s+\d{3}(?:[ \t]+([^\r\n]*))?~'; - /** Null until the first transfer. */ - private ?\CurlMultiHandle $multi = null; - /** The ext-curl check, made at the first post. */ private readonly CurlSupport $curl; - /** @var \Closure(string, int): int */ - private readonly \Closure $multiStatus; + /** @var CurlMulti the requests in transfer */ + private readonly CurlMulti $multi; + + private readonly int $connectTimeoutMs; + + private readonly int $totalTimeoutMs; /** @var list */ private array $waiting = []; - /** @var array transfers in `$multi`, keyed by `spl_object_id($handle)` */ - private array $active = []; - /** - * @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(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 + * + * @throws \InvalidArgumentException for a timeout curl cannot take (`CurlSupport::timeoutMilliseconds()`) */ public function __construct( - private readonly float $connectTimeoutSeconds = CurlTransport::CONNECT_TIMEOUT_SECONDS, - private readonly float $totalTimeoutSeconds = CurlTransport::TOTAL_TIMEOUT_SECONDS, + float $connectTimeoutSeconds = CurlTransport::CONNECT_TIMEOUT_SECONDS, + float $totalTimeoutSeconds = CurlTransport::TOTAL_TIMEOUT_SECONDS, ?\Closure $functionExists = null, ?\Closure $multiStatus = null, + private readonly ?\Closure $onSwallowed = null, ) { + $this->connectTimeoutMs = CurlSupport::timeoutMilliseconds($connectTimeoutSeconds); + $this->totalTimeoutMs = CurlSupport::timeoutMilliseconds($totalTimeoutSeconds); $this->curl = new CurlSupport(self::NO_CURL, $functionExists); - $this->multiStatus = $multiStatus ?? static fn(string $function, int $code): int => $code; + $this->multi = new CurlMulti($multiStatus); } #[\Override] @@ -78,7 +83,7 @@ public function post(HttpRequest $request, \Closure $onDone): void #[\Override] public function pump(): void { - if ($this->waiting === [] && $this->active === []) { + if ($this->waiting === [] && $this->multi->count() === 0) { return; // nothing posted (or nothing can be): no curl call } $this->guarded(function (): void { @@ -91,32 +96,25 @@ public function pump(): void #[\Override] public function pending(): int { - return count($this->waiting) + count($this->active); + return count($this->waiting) + $this->multi->count(); } #[\Override] public function waitForActivity(float $seconds): void { - if ($this->active === []) { + if ($this->multi->count() === 0) { return; // a waiting request starts at the next pump() } - $timeout = is_nan($seconds) ? 0.0 : max(0.0, min($seconds, self::MAX_WAIT_SECONDS)); + $timeout = CurlSupport::waitSeconds($seconds, self::MAX_WAIT_SECONDS); $this->guarded(function () use ($timeout): void { - if (curl_multi_select($this->multi(), $timeout) === -1) { - usleep(1000); - } + $this->multi->select($timeout); }); } /** Transfers in the multi handle (tests). */ public function inTransfer(): int { - return count($this->active); - } - - private function multi(): \CurlMultiHandle - { - return $this->multi ??= curl_multi_init(); + return $this->multi->count(); } /** @param \Closure(): void $work */ @@ -126,74 +124,50 @@ private function guarded(\Closure $work): void try { $work(); - } catch (\Throwable) { - // a side request must never reach the host (D32) + } 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 (count($this->active) < self::MAX_CONCURRENT && $this->waiting !== []) { + while ($this->multi->count() < self::MAX_CONCURRENT && $this->waiting !== []) { $next = array_shift($this->waiting); - $handle = curl_init(); - if ($handle === false) { - $this->finish($next['onDone'], HttpResponse::failed('cURL error: curl_init() failed')); - - continue; - } - $id = spl_object_id($handle); + $transfer = new HttpTransfer($next['onDone']); try { - curl_setopt_array($handle, $this->options($next['request'], $id)); - } catch (\Throwable $error) { - // a URL with a NUL byte (ValueError): the request fails, the others go on - $this->finish($next['onDone'], HttpResponse::failed(ErrorText::of($error))); - - continue; + $error = $this->multi->open($this->options($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 } - // Recorded before the add: a signal drain that runs right after the add must see this transfer. - $this->active[$id] = ['handle' => $handle, 'onDone' => $next['onDone'], 'reason' => '', 'body' => '']; - $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->finish($next['onDone'], HttpResponse::failed(CurlSupport::multiError($code))); + if ($error !== null) { + $this->finish($transfer->onDone, HttpResponse::failed($error)); } } } - /** Runs the transfers, then reads every transfer that this run finished and runs its callback. */ + /** Runs the transfers, then runs the callback of 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 no flush waits for them forever. - $active = $this->active; - $this->active = []; - foreach ($active as $entry) { - curl_multi_remove_handle($this->multi(), $entry['handle']); - $this->finish($entry['onDone'], HttpResponse::failed(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; - } - $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->finish($entry['onDone'], $info['result'] === CURLE_OK - ? new HttpResponse(curl_getinfo($info['handle'], CURLINFO_RESPONSE_CODE), $entry['reason'], $entry['body']) - : HttpResponse::failed(CurlSupport::transferError($info['handle'], $info['result']))); - } - } + $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)); + }, + ); } /** @param \Closure(HttpResponse): void $onDone */ @@ -201,35 +175,39 @@ private function finish(\Closure $onDone, HttpResponse $response): void { try { $onDone($response); - } catch (\Throwable) { - // our callbacks log and never throw; a broken one must not stop the others + } catch (\Throwable $error) { + $this->swallowed($error); // our callbacks log and never throw; a broken one must not stop the others } } - /** @return array */ - private function options(HttpRequest $request, int $id): array + /** + * The options of `$request`: the write and header functions first (`CurlSupport::postOptions()`). They hold only + * `$transfer`, so a finished handle frees it and no handle holds the channel. + * + * @return array + */ + private function options(HttpRequest $request, HttpTransfer $transfer): array { $headers = []; foreach ($request->headers as $name => $value) { $headers[] = $name . ': ' . $value; } + $capture = [ + CURLOPT_WRITEFUNCTION => static function (\CurlHandle $handle, string $data) use ($transfer): int { + // 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)); - return CurlSupport::postOptions($request->url, $request->body, $headers, $this->connectTimeoutSeconds, $this->totalTimeoutSeconds) + [ - CURLOPT_HEADERFUNCTION => function (\CurlHandle $handle, string $line) use ($id): int { - if (isset($this->active[$id]) && preg_match(self::STATUS_LINE, $line, $match) === 1) { - $this->active[$id]['reason'] = $match[1] ?? ''; - } - - return strlen($line); + return strlen($data); // a shorter return value would abort the transfer }, - CURLOPT_WRITEFUNCTION => function (\CurlHandle $handle, string $data) use ($id): int { - if (isset($this->active[$id])) { - // Up to MAX_BODY_BYTES are kept; the rest is read and dropped. - $this->active[$id]['body'] .= substr($data, 0, self::MAX_BODY_BYTES - strlen($this->active[$id]['body'])); + CURLOPT_HEADERFUNCTION => static function (\CurlHandle $handle, string $line) use ($transfer): int { + if (preg_match(self::STATUS_LINE, $line, $match) === 1) { + $transfer->reason = $match[1] ?? ''; } - return strlen($data); // a shorter return value would abort the transfer + return strlen($line); }, ]; + + return CurlSupport::postOptions($capture, $request->url, $request->body, $headers, $this->connectTimeoutMs, $this->totalTimeoutMs); } } diff --git a/src/Internal/Transport/CurlMulti.php b/src/Internal/Transport/CurlMulti.php new file mode 100644 index 0000000..8d83224 --- /dev/null +++ b/src/Internal/Transport/CurlMulti.php @@ -0,0 +1,121 @@ + transfers in `$multi`, keyed by `spl_object_id($handle)` */ + private array $transfers = []; + + /** @var \Closure(string, int): int */ + private readonly \Closure $multiStatus; + + /** + * @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; 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]; + $code = ($this->multiStatus)('curl_multi_add_handle', curl_multi_add_handle($this->multi(), $handle)); + if ($code === CURLM_OK) { + return null; + } + unset($this->transfers[$id]); + // PHP keeps the handle in its list of the multi handle even when libcurl refused it. + curl_multi_remove_handle($this->multi(), $handle); + + return CurlSupport::multiError($code); + } + + /** + * 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. + * + * @param \Closure(TPayload, \CurlHandle, int): void $finished + * @param \Closure(TPayload, string): void $failed + */ + public function run(\Closure $finished, \Closure $failed): void + { + $code = ($this->multiStatus)('curl_multi_exec', curl_multi_exec($this->multi(), $running)); + if ($code !== CURLM_OK) { + $transfers = $this->transfers; + $this->transfers = []; + 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; + } + $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. */ + public function count(): int + { + return count($this->transfers); + } + + private function multi(): \CurlMultiHandle + { + return $this->multi ??= curl_multi_init(); + } +} diff --git a/src/Internal/Transport/CurlSupport.php b/src/Internal/Transport/CurlSupport.php index 713c2a5..94492d4 100644 --- a/src/Internal/Transport/CurlSupport.php +++ b/src/Internal/Transport/CurlSupport.php @@ -5,16 +5,21 @@ namespace AgentCat\Internal\Transport; use AgentCat\Internal\Logging\Log; +use AgentCat\Internal\SdkVersion; /** * What `CurlTransport` and `CurlHttpChannel` share about ext-curl: the check that every curl function they call * exists (a PHP without ext-curl, or with some of its functions in `disable_functions`), made once and logged once - * (D32), the options of a `POST`, and the texts of a failed transfer and of a failed multi handle. + * (D32), the User-Agent, the timeouts and the options of a `POST`, the bound of one wait, and the texts of a failed + * transfer and of a failed multi handle. `CurlMulti` runs the transfers. * * @internal */ final class CurlSupport { + /** The longest curl timeout: 2^31 - 1 ms, what a 32-bit C `long` (curl's option type on Windows) carries. */ + public const float MAX_TIMEOUT_SECONDS = 2_147_483.647; + /** Every curl function `CurlTransport` and `CurlHttpChannel` call. */ private const array FUNCTIONS = [ 'curl_init', 'curl_setopt_array', 'curl_error', 'curl_strerror', 'curl_getinfo', 'curl_multi_init', @@ -57,30 +62,65 @@ 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`: `$headers` (`Name: value` lines) and the SDK User-Agent, http and https only, no - * redirects, no signals, connect and total timeouts rounded to milliseconds. The response headers stay out of - * the body. + * 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 list $headers + * @param array $capture + * @param list $headers * * @return array */ - public static function postOptions(string $url, string $body, array $headers, float $connectTimeoutSeconds, float $totalTimeoutSeconds): array + public static function postOptions(array $capture, string $url, string $body, array $headers, int $connectTimeoutMs, int $totalTimeoutMs): array { - return [ + 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 => CurlTransport::userAgent(), + CURLOPT_USERAGENT => self::userAgent(), CURLOPT_HEADER => false, CURLOPT_FOLLOWLOCATION => false, CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, CURLOPT_NOSIGNAL => true, - CURLOPT_CONNECTTIMEOUT_MS => (int) round($connectTimeoutSeconds * 1000), - CURLOPT_TIMEOUT_MS => (int) round($totalTimeoutSeconds * 1000), + CURLOPT_CONNECTTIMEOUT_MS => $connectTimeoutMs, + CURLOPT_TIMEOUT_MS => $totalTimeoutMs, ]; } diff --git a/src/Internal/Transport/CurlTransport.php b/src/Internal/Transport/CurlTransport.php index 7f57730..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,17 +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)'; - /** Null until the first transfer. */ - private ?\CurlMultiHandle $multi = null; - /** The ext-curl check, made at the first delivery. */ private readonly CurlSupport $curl; + /** @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 = []; @@ -49,31 +49,33 @@ 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->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] @@ -122,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(); @@ -131,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; } @@ -175,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] @@ -189,13 +189,7 @@ public function curlOptions(Delivery $delivery): array { $headers = ['Content-Type: application/json', 'Accept: application/json']; - return CurlSupport::postOptions($delivery->url, $delivery->body, $headers, $this->connectTimeoutSeconds, $this->totalTimeoutSeconds) - + [CURLOPT_RETURNTRANSFER => true]; - } - - 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); } /** @@ -220,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) { @@ -230,53 +224,26 @@ 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'], CurlSupport::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'], 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; - } - $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 diff --git a/src/Internal/Transport/HttpTransfer.php b/src/Internal/Transport/HttpTransfer.php new file mode 100644 index 0000000..8d257f3 --- /dev/null +++ b/src/Internal/Transport/HttpTransfer.php @@ -0,0 +1,23 @@ + what the channels of this test swallowed (I2: the normal paths swallow nothing) */ + private array $swallowed = []; + #[\Override] public static function setUpBeforeClass(): void { @@ -47,13 +52,14 @@ protected function setUp(): void 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 = new CurlHttpChannel(); + $channel = $this->channel(); $responses = []; $channel->post( new HttpRequest($url . '/v1/traces', ['Content-Type' => 'application/json', 'x-api-key' => 'k'], '{"a":1}'), @@ -86,8 +92,8 @@ static function (HttpResponse $response) use (&$responses): void { #[MaximumDuration(3000)] public function testARefusedConnectionAndATimeoutAreErrorsNotExceptions(): void { - $slow = $this->collector()->scenario([], ['status' => 200, 'delayMs' => 2000]); - $channel = new CurlHttpChannel(1.0, 0.3); + $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; @@ -110,7 +116,7 @@ public function testOnlyHttpAndHttpsAreFetched(): void { $target = $this->collector()->scenario(); $moved = $this->collector()->scenario([['status' => 301, 'body' => '', 'location' => $target . '/followed']]); - $channel = new CurlHttpChannel(); + $channel = $this->channel(); $responses = []; $record = static function (HttpResponse $response) use (&$responses): void { $responses[] = $response; @@ -137,7 +143,7 @@ public function testOnlyHttpAndHttpsAreFetched(): void public function testACallbackCanPostTheNextRequestOfASequence(): void { $url = $this->collector()->scenario(); - $channel = new CurlHttpChannel(); + $channel = $this->channel(); $order = []; $channel->post(new HttpRequest($url . '/one', [], '1'), static function () use ($channel, $url, &$order): void { $order[] = 'one'; @@ -155,7 +161,7 @@ public function testACallbackCanPostTheNextRequestOfASequence(): void public function testARequestThatACallbackPostsStartsInTheSamePump(): void { $url = $this->collector()->scenario(); - $channel = new CurlHttpChannel(); + $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 {}); @@ -175,18 +181,29 @@ public function testARequestThatACallbackPostsStartsInTheSamePump(): void $this->assertCount(2, $this->collector()->requests($url)); } - /** A long-running worker keeps its channel: a finished transfer leaves no handle (and no reference cycle) behind. */ + /** + * 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 = new CurlHttpChannel(); - $channel->post(new HttpRequest($url . '/a', [], '{}'), static function (): void {}); + $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)); - $weak = \WeakReference::create($channel); + $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'); } @@ -194,7 +211,7 @@ public function testAFinishedTransferLeavesNothingBehind(): void public function testTheResponseBodyIsCappedAt64KiB(): void { $url = $this->collector()->scenario([['status' => 500, 'body' => str_repeat('x', 70_000)]]); - $channel = new CurlHttpChannel(); + $channel = $this->channel(); $body = null; $channel->post(new HttpRequest($url . '/batch', [], '{}'), static function (HttpResponse $response) use (&$body): void { $body = $response->body; @@ -205,12 +222,15 @@ public function testTheResponseBodyIsCappedAt64KiB(): void $this->assertSame(CurlHttpChannel::MAX_BODY_BYTES, strlen((string) $body)); } - /** 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. */ - #[MaximumDuration(3000)] + /** + * 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 = new CurlHttpChannel(); + $channel = $this->channel(); for ($i = 0; $i < 12; ++$i) { $channel->post(new HttpRequest($url . '/r' . $i, [], '{}'), static function (): void {}); } @@ -219,7 +239,14 @@ public function testAtMostTenTransfersRunAtOnce(): void $this->assertSame(12, $channel->pending()); $this->assertSame(CurlHttpChannel::MAX_CONCURRENT, $channel->inTransfer()); - $this->assertTrue($this->drain($channel, 3.0)); + $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. */ @@ -227,7 +254,7 @@ public function testAtMostTenTransfersRunAtOnce(): void public function testAFailingExecFailsEveryTransferInsteadOfWaitingForever(): void { $url = $this->collector()->scenario(); - $channel = new CurlHttpChannel( + $channel = $this->channel( multiStatus: static fn(string $function, int $code): int => $function === 'curl_multi_exec' ? CURLM_INTERNAL_ERROR : $code, ); $errors = []; @@ -248,7 +275,7 @@ public function testAfterAFailedExecTheFailedTransfersNeverReachTheServer(): voi { $url = $this->collector()->scenario(); $execs = new \ArrayObject(); - $channel = new CurlHttpChannel(multiStatus: static function (string $function, int $code) use ($execs): int { + $channel = $this->channel(multiStatus: static function (string $function, int $code) use ($execs): int { if ($function !== 'curl_multi_exec') { return $code; } @@ -280,7 +307,7 @@ public function testAFailedAddFailsThatRequestOnly(): void { $url = $this->collector()->scenario(); $adds = new \ArrayObject(); - $channel = new CurlHttpChannel(multiStatus: static function (string $function, int $code) use ($adds): int { + $channel = $this->channel(multiStatus: static function (string $function, int $code) use ($adds): int { if ($function !== 'curl_multi_add_handle') { return $code; } @@ -289,24 +316,26 @@ public function testAFailedAddFailsThatRequestOnly(): void return count($adds) === 1 ? CURLM_INTERNAL_ERROR : $code; }); $responses = []; - $record = static function (HttpResponse $response) use (&$responses): void { + $refused = static function (HttpResponse $response) use (&$responses): void { $responses[] = $response; }; - $channel->post(new HttpRequest($url . '/refused', [], '{}'), $record); - $channel->post(new HttpRequest($url . '/sent', [], '{}'), $record); + $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 it holds the channel no longer. + // 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); - $weak = \WeakReference::create($channel); - unset($channel); - $this->assertNull($weak->get()); + $this->assertNull($request->get()); } /** `$onDone` runs once for every request: one that curl refuses to set up fails through its callback too. */ @@ -314,7 +343,7 @@ public function testAFailedAddFailsThatRequestOnly(): void public function testARequestCurlCannotSetUpFailsThroughItsCallback(): void { $url = $this->collector()->scenario(); - $channel = new CurlHttpChannel(); + $channel = $this->channel(); $responses = []; $record = static function (HttpResponse $response) use (&$responses): void { $responses[] = $response; @@ -334,11 +363,69 @@ public function testARequestCurlCannotSetUpFailsThroughItsCallback(): void $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 = new CurlHttpChannel(); + $channel = $this->channel(); $statuses = []; $channel->post(new HttpRequest($url . '/a', [], '{}'), static function (): never { throw new \RuntimeException('a broken callback'); @@ -351,6 +438,8 @@ public function testAThrowingCallbackDoesNotStopTheOthers(): void $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()`. */ @@ -358,7 +447,7 @@ public function testAThrowingCallbackDoesNotStopTheOthers(): void public function testAWarningInACallbackStaysInsideTheChannel(): void { $url = $this->collector()->scenario(); - $channel = new CurlHttpChannel(); + $channel = $this->channel(); $finished = new \ArrayObject(); $channel->post(new HttpRequest($url . '/a', [], '{}'), static function () use ($finished): void { trigger_error('exporter warning', E_USER_WARNING); @@ -386,8 +475,9 @@ public function testAWarningInACallbackStaysInsideTheChannel(): void } /** - * Ruling P7: only a wait with a transfer in progress reaches the NaN guard. Without the guard, PHP 8.3 computes a - * 0.0 bound and PHP 8.4+ a NAN one (`max(0.0, NAN)`), which curl_multi_select() rejects with a ValueError. + * 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 @@ -396,7 +486,7 @@ public function testAWaitWithANanOrNegativeBoundReturnsAtOnceDuringATransfer(): $this->assertNotFalse($server, 'no local server: ' . $errorMessage); $address = stream_socket_get_name($server, false); $this->assertIsString($address); - $channel = new CurlHttpChannel(); + $channel = $this->channel(); $errors = []; $channel->post(new HttpRequest('http://' . $address . '/v1/logs', [], '{}'), static function (HttpResponse $response) use (&$errors): void { $errors[] = $response->error; @@ -431,6 +521,17 @@ public function testAWaitWithANanOrNegativeBoundReturnsAtOnceDuringATransfer(): $this->assertMatchesRegularExpression('/\AcURL error \d+: /', (string) $errors[0]); } + /** @param (\Closure(string, int): int)|null $multiStatus */ + private function channel( + float $connectTimeoutSeconds = CurlTransport::CONNECT_TIMEOUT_SECONDS, + float $totalTimeoutSeconds = CurlTransport::TOTAL_TIMEOUT_SECONDS, + ?\Closure $multiStatus = null, + ): CurlHttpChannel { + return new CurlHttpChannel($connectTimeoutSeconds, $totalTimeoutSeconds, multiStatus: $multiStatus, onSwallowed: function (\Throwable $error): void { + $this->swallowed[] = $error; + }); + } + private function drain(HttpChannel $channel, float $seconds): bool { $until = microtime(true) + $seconds; diff --git a/tests/Integration/Transport/CurlTransportTest.php b/tests/Integration/Transport/CurlTransportTest.php index 05a209f..6001815 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 { diff --git a/tests/Unit/Internal/Transport/CurlHttpChannelUnitTest.php b/tests/Unit/Internal/Transport/CurlHttpChannelUnitTest.php index d1bc06c..95c71c4 100644 --- a/tests/Unit/Internal/Transport/CurlHttpChannelUnitTest.php +++ b/tests/Unit/Internal/Transport/CurlHttpChannelUnitTest.php @@ -9,10 +9,11 @@ use AgentCat\Internal\Transport\HttpRequest; use AgentCat\Internal\Transport\HttpResponse; use AgentCat\Tests\Support\MemoryLogger; +use AgentCat\Tests\Support\Thrown; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; -/** No transfer starts here: nothing is pumped, or curl is "missing". tests/Integration/Transport covers the transfers. */ +/** No transfer starts here: nothing is pumped, curl is "missing", or every add is refused. tests/Integration/Transport covers the transfers. */ final class CurlHttpChannelUnitTest extends TestCase { private const string FULL = 'WARN: AgentCat telemetry export queue full, dropping the oldest request'; @@ -36,15 +37,47 @@ protected function tearDown(): void public function testAFullQueueDropsTheOldestRequestWithOneLineEach(): void { - $channel = new CurlHttpChannel(); + // Every add is refused: one pump then fails each waiting request through its callback, and none is sent. + $swallowed = []; + $channel = 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; + }, + ); + $ran = []; for ($i = 0; $i < CurlHttpChannel::MAX_WAITING + 2; ++$i) { - $channel->post(new HttpRequest('http://127.0.0.1:9/r' . $i, [], '{}'), static function (): void {}); + $channel->post(new HttpRequest('http://127.0.0.1:9/r' . $i, [], '{}'), static function () use (&$ran, $i): void { + $ran[] = $i; + }); } $this->assertSame(1000, $channel->pending()); $this->assertSame(0, $channel->inTransfer()); $this->assertSame([self::FULL, self::FULL], $this->logger->messages()); + $channel->pump(); + $this->assertSame(range(2, 1001), $ran, 'r0 and r1, the oldest, were dropped'); + $this->assertSame(0, $channel->pending()); + $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 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()); + } +} From 0965bcef38ad194b45614ea1aaf9a9bb5fa59dff Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Sat, 17 Oct 2026 21:45:30 +0000 Subject: [PATCH 05/35] test: pin the first transfer step of start() and the handle removal after a failed exec --- .../Transport/CurlHttpChannelTest.php | 22 ++++++++++++------- .../Transport/CurlTransportTest.php | 18 +++++++++++++++ 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/tests/Integration/Transport/CurlHttpChannelTest.php b/tests/Integration/Transport/CurlHttpChannelTest.php index e7a253f..4ebd72a 100644 --- a/tests/Integration/Transport/CurlHttpChannelTest.php +++ b/tests/Integration/Transport/CurlHttpChannelTest.php @@ -284,22 +284,28 @@ public function testAfterAFailedExecTheFailedTransfersNeverReachTheServer(): voi return count($execs) === 1 ? CURLM_INTERNAL_ERROR : $code; }); $errors = []; - $record = static function (HttpResponse $response) use (&$errors): void { + $failed = static function (HttpResponse $response) use (&$errors): void { $errors[] = $response->error; }; - $channel->post(new HttpRequest($url . '/a', [], '{}'), $record); - $channel->post(new HttpRequest($url . '/b', [], '{}'), $record); + $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', [], '{}'), $record); + $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. - $requests = $this->collector()->requests($url); - $this->assertCount(1, $requests); - $this->assertSame('/c', $requests[0]->path); + // 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)] diff --git a/tests/Integration/Transport/CurlTransportTest.php b/tests/Integration/Transport/CurlTransportTest.php index 6001815..affe71e 100644 --- a/tests/Integration/Transport/CurlTransportTest.php +++ b/tests/Integration/Transport/CurlTransportTest.php @@ -990,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(); From b726029dd0cf872db55fcc3459a09fdd53ca3d03 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Sat, 17 Oct 2026 21:45:30 +0000 Subject: [PATCH 06/35] ci: give Infection a 120 s per-mutant timeout so no mutant is skipped --- infection.json5 | 3 +++ 1 file changed, 3 insertions(+) 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": { From adc88c2daaba347a70731e202afff449aebfb7b5 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Sat, 17 Oct 2026 22:02:54 +0000 Subject: [PATCH 07/35] feat: let the event queue pump and drain exporter requests within its 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). --- src/Internal/Delivery/EventQueue.php | 161 ++++++++-- src/Internal/Delivery/QueueCompanion.php | 23 ++ src/Internal/Delivery/ShutdownFlusher.php | 8 +- .../Delivery/EventQueueDeliveryTest.php | 41 +++ tests/Support/RecordingCompanion.php | 46 +++ tests/Support/RecordingHttpChannel.php | 8 +- .../Unit/Internal/Delivery/EventQueueTest.php | 301 ++++++++++++++++++ .../Internal/Delivery/ShutdownFlusherTest.php | 33 ++ 8 files changed, 592 insertions(+), 29 deletions(-) create mode 100644 src/Internal/Delivery/QueueCompanion.php create mode 100644 tests/Support/RecordingCompanion.php diff --git a/src/Internal/Delivery/EventQueue.php b/src/Internal/Delivery/EventQueue.php index 32fe5b9..4cefd33 100644 --- a/src/Internal/Delivery/EventQueue.php +++ b/src/Internal/Delivery/EventQueue.php @@ -10,8 +10,10 @@ use AgentCat\Internal\Pipeline\QueuedEvent; use AgentCat\Internal\Support\ErrorText; use AgentCat\Internal\Transport\ApiBaseUrl; +use AgentCat\Internal\Transport\CurlHttpChannel; 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 +23,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, then pumps the channel; `flush()` and `shutdown()` wait for the + * channel inside the same timeout. + * * @internal */ final class EventQueue @@ -34,6 +40,9 @@ 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; + private static ?self $global = null; /** @var \SplQueue events not yet in delivery */ @@ -52,9 +61,13 @@ final class EventQueue /** 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 */ public function __construct( private readonly EventProcessor $processor = new EventProcessor(), @@ -63,6 +76,7 @@ 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(), ) { $this->waiting = new \SplQueue(); $this->encode = $encode ?? WireEncoder::encode(...); @@ -104,6 +118,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 +180,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,8 +201,9 @@ 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 and diagnostics requests until no event waits + * or is in flight and no side request is pending (true), or the timeout passes (false); null = no limit. A + * re-entrant call (from a hook or an exporter) returns false at once. */ public function flush(?float $timeoutSeconds = null): bool { @@ -153,16 +211,23 @@ public function flush(?float $timeoutSeconds = null): bool } /** - * 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 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. When time is left after that delivery, each companion + * finishes (diagnostics posts its buffer) and one more delivery pass, to the same deadline, sends what they posted; + * that pass ticks no companion, so `finish()` is a companion's last call in a shutdown. */ 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)); + $this->deliverUntil($deadline); + if ($this->companions !== [] && !$this->shutdownDeadlinePassed()) { + $this->finishCompanions(); + $this->deliverUntil($deadline, tick: false); + } } if (!$this->waiting->isEmpty()) { Log::write(sprintf('Shutting down with %d events still in queue', $this->waiting->count())); @@ -187,7 +252,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 +273,13 @@ private function budget(float $timeoutSeconds): float } /** - * The delivery loop of `flush()` and `shutdown()`. A re-entrant call returns false at once. + * The delivery loop of `flush()` and `shutdown()`: done when no event waits or is in flight and the side channel is + * idle. 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 */ - private function deliverUntil(\Closure $deadline): bool + private function deliverUntil(\Closure $deadline, bool $tick = true): bool { if ($this->busy) { return false; @@ -218,8 +289,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() === 0) { return true; } if ($this->transport->now() >= $until) { @@ -227,7 +298,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 +310,23 @@ 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. `$until` is never NaN. */ + 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(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 +336,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..2e42a0d --- /dev/null +++ b/src/Internal/Delivery/QueueCompanion.php @@ -0,0 +1,23 @@ +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/tests/Integration/Delivery/EventQueueDeliveryTest.php b/tests/Integration/Delivery/EventQueueDeliveryTest.php index 2ddc8c8..c051350 100644 --- a/tests/Integration/Delivery/EventQueueDeliveryTest.php +++ b/tests/Integration/Delivery/EventQueueDeliveryTest.php @@ -12,7 +12,10 @@ 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\RetryPolicy; use AgentCat\Tests\Integration\Support\MockCollector; use AgentCat\Tests\Support\JsonAssert; @@ -149,6 +152,44 @@ 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'); + } + private function collector(): MockCollector { $this->assertInstanceOf(MockCollector::class, self::$collector); 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 index ca59221..2d34358 100644 --- a/tests/Support/RecordingHttpChannel.php +++ b/tests/Support/RecordingHttpChannel.php @@ -14,6 +14,9 @@ final class RecordingHttpChannel implements HttpChannel /** @var list */ public array $requests = []; + /** @var list the `$seconds` of every waitForActivity() call */ + public array $waits = []; + /** @var list */ private array $held = []; @@ -51,7 +54,10 @@ public function pending(): int } #[\Override] - public function waitForActivity(float $seconds): void {} + public function waitForActivity(float $seconds): void + { + $this->waits[] = $seconds; + } /** @return list the URLs, in post order */ public function urls(): array diff --git a/tests/Unit/Internal/Delivery/EventQueueTest.php b/tests/Unit/Internal/Delivery/EventQueueTest.php index e996dcd..91b7539 100644 --- a/tests/Unit/Internal/Delivery/EventQueueTest.php +++ b/tests/Unit/Internal/Delivery/EventQueueTest.php @@ -12,11 +12,17 @@ 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\Group; @@ -723,6 +729,282 @@ 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()); + } + + /** Neither side waits on the other's sockets: 10 ms on the transport, then 10 ms on the channel, up to the deadline. */ + #[Group('ac-06-63')] + public function testFlushWaitsInShortSlicesWhileASideRequestIsPending(): void + { + $transport = new RecordingTransport(); + $channel = new RecordingHttpChannel(complete: false); + $queue = new EventQueue(transport: $transport, channel: $channel); + $channel->post(new HttpRequest('http://exporter.test/a', [], '{}'), static function (): void {}); + + $this->assertFalse($queue->flush(0.1)); + + $this->assertEqualsWithDelta(0.1, $transport->now(), 1e-9, 'the last slice ends at the deadline'); + $this->assertGreaterThanOrEqual(10, $transport->waits); + $this->assertLessThanOrEqual(11, $transport->waits); + $this->assertSame(array_fill(0, $transport->waits, 0.01), $channel->waits); + } + + #[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' turn never comes. */ + #[Group('ac-03-9')] + public function testCompanionsAreNotFinishedOnceTheSharedDeadlineHasPassed(): 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 whole budget + $queue->shutdown(); + + $this->assertSame(['tick', 'tick'], $companion->calls, 'no finish, and nothing after the deadline'); + $this->assertEqualsWithDelta(1.0, $transport->now(), PHP_FLOAT_EPSILON); + } + + /** `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 +1018,23 @@ 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 {}); + } + }; + } } 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; From 95abb7fb3035fb9a18ff07bc358fe5d8fb07e32d Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Sat, 17 Oct 2026 22:27:08 +0000 Subject: [PATCH 08/35] fix: keep the end of the shutdown budget for queue companions - 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. --- src/Internal/Delivery/EventQueue.php | 60 ++++-- src/Internal/Delivery/QueueCompanion.php | 10 +- tests/Support/RecordingHttpChannel.php | 9 +- .../Unit/Internal/Delivery/EventQueueTest.php | 187 ++++++++++++++++-- 4 files changed, 240 insertions(+), 26 deletions(-) diff --git a/src/Internal/Delivery/EventQueue.php b/src/Internal/Delivery/EventQueue.php index 4cefd33..1af6d20 100644 --- a/src/Internal/Delivery/EventQueue.php +++ b/src/Internal/Delivery/EventQueue.php @@ -11,6 +11,7 @@ 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; @@ -24,8 +25,8 @@ * 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, then pumps the channel; `flush()` and `shutdown()` wait for the - * channel inside the same timeout. + * (`attach()`): every pass ticks each companion (except the pass after `finish()`), then pumps the channel; `flush()` + * and `shutdown()` wait for the channel inside the same timeout. * * @internal */ @@ -43,6 +44,11 @@ final class EventQueue /** 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 */ @@ -214,19 +220,24 @@ public function flush(?float $timeoutSeconds = null): bool * Stops accepting events, then delivers the events and the side requests 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. When time is left after that delivery, each companion - * finishes (diagnostics posts its buffer) and one more delivery pass, to the same deadline, sends what they posted; - * that pass ticks no companion, so `finish()` is a companion's last call in a shutdown. + * 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($deadline); - if ($this->companions !== [] && !$this->shutdownDeadlinePassed()) { - $this->finishCompanions(); - $this->deliverUntil($deadline, tick: false); + if ($this->companions === []) { + $this->deliverUntil($deadline); + } else { + $this->deliverWithCompanions($deadline); } } if (!$this->waiting->isEmpty()) { @@ -272,6 +283,29 @@ private function budget(float $timeoutSeconds): float return is_nan($timeoutSeconds) ? 0.0 : max(0.0, $timeoutSeconds); } + /** + * 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 fixes the shared deadline at its first call + */ + 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 the side channel is * idle. A re-entrant call returns false at once. @@ -310,7 +344,11 @@ private function deliverUntil(\Closure $deadline, bool $tick = true): bool } } - /** Waits on the transport alone, or, while side requests are pending, a slice on each. `$until` is never NaN. */ + /** + * 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) { @@ -319,7 +357,7 @@ private function wait(float $until): void return; } $this->transport->waitForActivity(min($until, $this->transport->now() + self::SLICE_SECONDS)); - $this->channel->waitForActivity(self::SLICE_SECONDS); + $this->channel->waitForActivity(CurlSupport::waitSeconds($until - $this->transport->now(), self::SLICE_SECONDS)); } /** diff --git a/src/Internal/Delivery/QueueCompanion.php b/src/Internal/Delivery/QueueCompanion.php index 2e42a0d..e8eb673 100644 --- a/src/Internal/Delivery/QueueCompanion.php +++ b/src/Internal/Delivery/QueueCompanion.php @@ -12,10 +12,16 @@ */ interface QueueCompanion { - /** On every pass of `pump()`, `flush()` and `shutdown()`, before the side channel is pumped. Must not block. */ + /** + * On every pass of `pump()`, `flush()` and `shutdown()`, except the pass after `finish()`, before the side channel + * is pumped. Must not block. + */ public function tick(): void; - /** Once per `shutdown()`, after the events are delivered and before the last wait; may post a last request. */ + /** + * Once per `shutdown()` with time left: after the events and side requests are delivered, or when only the + * finish reserve of the budget is left (10 %, at most 0.5 s), and before the last wait. May post a last request. + */ public function finish(): void; /** Whether anything is left to send (buffered lines). */ diff --git a/tests/Support/RecordingHttpChannel.php b/tests/Support/RecordingHttpChannel.php index 2d34358..298d5f9 100644 --- a/tests/Support/RecordingHttpChannel.php +++ b/tests/Support/RecordingHttpChannel.php @@ -22,11 +22,15 @@ final class RecordingHttpChannel implements HttpChannel private readonly HttpResponse $default; - /** @param list $responses the answers to requests 0, 1, 2, … */ + /** + * @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'); } @@ -57,6 +61,9 @@ public function pending(): int public function waitForActivity(float $seconds): void { $this->waits[] = $seconds; + if ($this->onWait instanceof \Closure) { + ($this->onWait)($seconds); + } } /** @return list the URLs, in post order */ diff --git a/tests/Unit/Internal/Delivery/EventQueueTest.php b/tests/Unit/Internal/Delivery/EventQueueTest.php index 91b7539..3d481ca 100644 --- a/tests/Unit/Internal/Delivery/EventQueueTest.php +++ b/tests/Unit/Internal/Delivery/EventQueueTest.php @@ -25,6 +25,7 @@ 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\TestCase; use Psr\Log\LogLevel; @@ -757,21 +758,45 @@ public function testFlushReportsFalseWhileASideRequestIsStillPending(): void $this->assertTrue($queue->hasWork()); } - /** Neither side waits on the other's sockets: 10 ms on the transport, then 10 ms on the channel, up to the deadline. */ + /** + * @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')] - public function testFlushWaitsInShortSlicesWhileASideRequestIsPending(): void + #[DataProvider('sliceDeadlines')] + public function testFlushWaitsInShortSlicesThatNeverEndPastTheDeadline(float $timeout, array $transportEnds, array $channelSlices): void { $transport = new RecordingTransport(); - $channel = new RecordingHttpChannel(complete: false); + $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(0.1)); + $this->assertFalse($queue->flush($timeout)); - $this->assertEqualsWithDelta(0.1, $transport->now(), 1e-9, 'the last slice ends at the deadline'); - $this->assertGreaterThanOrEqual(10, $transport->waits); - $this->assertLessThanOrEqual(11, $transport->waits); - $this->assertSame(array_fill(0, $transport->waits, 0.01), $channel->waits); + $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')] @@ -849,21 +874,159 @@ public function testAThrowingSideChannelNeverStopsTheQueue(): void $this->assertTrue($queue->flush()); } - /** The C2 setup of a shutdown after the shared deadline: the companions' turn never comes. */ + /** 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 testCompanionsAreNotFinishedOnceTheSharedDeadlineHasPassed(): void + 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(); // the held event uses the whole budget $queue->shutdown(); - $this->assertSame(['tick', 'tick'], $companion->calls, 'no finish, and nothing after the deadline'); + $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. */ From 077fa393f4bbc63f332cc29019224a94e06a2dfc Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Sat, 17 Oct 2026 22:34:20 +0000 Subject: [PATCH 09/35] feat: add typed exporter configurations and the exporters, disableDiagnostics and disableSignalHandlers options --- src/Exporters/DatadogExporter.php | 25 ++++ src/Exporters/OtlpExporter.php | 21 +++ src/Exporters/PostHogExporter.php | 23 +++ src/Exporters/SentryExporter.php | 25 ++++ src/Internal/Telemetry/ExporterConfigs.php | 127 ++++++++++++++++ src/Options.php | 40 +++++ tests/Unit/Exporters/ExporterClassesTest.php | 79 ++++++++++ .../Telemetry/ExporterConfigsTest.php | 141 ++++++++++++++++++ tests/Unit/OptionsTest.php | 104 +++++++++++++ 9 files changed, 585 insertions(+) create mode 100644 src/Exporters/DatadogExporter.php create mode 100644 src/Exporters/OtlpExporter.php create mode 100644 src/Exporters/PostHogExporter.php create mode 100644 src/Exporters/SentryExporter.php create mode 100644 src/Internal/Telemetry/ExporterConfigs.php create mode 100644 tests/Unit/Exporters/ExporterClassesTest.php create mode 100644 tests/Unit/Internal/Telemetry/ExporterConfigsTest.php 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/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/Options.php b/src/Options.php index d568bf8..68d52e6 100644 --- a/src/Options.php +++ b/src/Options.php @@ -4,7 +4,12 @@ namespace AgentCat; +use AgentCat\Exporters\DatadogExporter; +use AgentCat\Exporters\OtlpExporter; +use AgentCat\Exporters\PostHogExporter; +use AgentCat\Exporters\SentryExporter; use AgentCat\Internal\Logging\Log; +use AgentCat\Internal\Telemetry\ExporterConfigs; /** * Options for tracking an MCP server. Use named arguments: the argument names are public API. @@ -16,6 +21,7 @@ private const array KEYS = [ 'enableReportMissing', 'enableTracing', 'enableToolCallContext', 'customContextDescription', 'enableAgentTracking', 'resolveSessionId', 'identify', 'redactSensitiveInformation', 'redactEvent', 'apiBaseUrl', 'eventTags', 'eventProperties', + 'exporters', 'disableDiagnostics', 'disableSignalHandlers', ]; /** @@ -31,6 +37,15 @@ * @param string|null $apiBaseUrl Base URL of the AgentCat API; null uses the default. * @param \Closure(ToolCall): (array|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: AgentCat's own log lines (the lines of agentcat.log), sent to + * AgentCat to find broken installs. The lines can contain session IDs, actor IDs, tool names and error messages. + * The first track() in the process decides. DISABLE_DIAGNOSTICS also turns them off, or on with false; they are + * off in test environments. + * @param bool $disableSignalHandlers Does not install the SIGTERM and SIGINT handlers that deliver queued events before + * the process ends. Use it when your application installs its own handlers. Default: false. */ public function __construct( public bool $enableReportMissing = true, @@ -45,6 +60,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, ) {} /** @@ -76,6 +94,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::bool($config, 'disableDiagnostics', false), + disableSignalHandlers: self::bool($config, 'disableSignalHandlers', false), ); } @@ -93,6 +114,25 @@ private static function bool(array $config, string $key, bool $default): bool return $default; } + /** + * @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/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/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/OptionsTest.php b/tests/Unit/OptionsTest.php index 48937b8..0e9a4aa 100644 --- a/tests/Unit/OptionsTest.php +++ b/tests/Unit/OptionsTest.php @@ -5,6 +5,10 @@ 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; @@ -107,6 +111,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 +128,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 +193,98 @@ 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', 'disableDiagnostics' => 'yes', 'disableSignalHandlers' => 1]); + + $this->assertNull($options->exporters); + $this->assertFalse($options->disableDiagnostics); + $this->assertFalse($options->disableSignalHandlers); + $this->assertSame([ + 'WARN: AgentCat option "exporters" must be array or null, got string; the default is used', + 'WARN: AgentCat option "disableDiagnostics" must be bool, got string; the default is used', + 'WARN: AgentCat option "disableSignalHandlers" must be bool, got int; the default is used', + ], $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()); + } } From 34bee7a3379bccc61fcaf07348a032c93115eda7 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Sat, 17 Oct 2026 22:44:38 +0000 Subject: [PATCH 10/35] feat: build TypeScript-identical OTLP and Datadog export payloads --- src/Internal/Support/Js.php | 2 +- src/Internal/Support/Values.php | 6 + .../Telemetry/Datadog/DatadogPayload.php | 111 ++++++++++ src/Internal/Telemetry/ExportErrors.php | 27 +++ src/Internal/Telemetry/ExportJson.php | 42 ++++ src/Internal/Telemetry/JsError.php | 20 ++ src/Internal/Telemetry/Otlp/OtlpPayload.php | 118 +++++++++++ tests/Golden/ExporterGoldenTest.php | 112 ++++++++++ tests/Support/ExporterFixtures.php | 73 +++++++ tests/Support/ExporterGolden.php | 79 +++++++ tests/Unit/Internal/Support/ValuesTest.php | 7 + .../Telemetry/Datadog/DatadogPayloadTest.php | 187 +++++++++++++++++ .../Internal/Telemetry/ExportErrorsTest.php | 44 ++++ .../Internal/Telemetry/ExportJsonTest.php | 76 +++++++ .../Telemetry/Otlp/OtlpPayloadTest.php | 198 ++++++++++++++++++ 15 files changed, 1101 insertions(+), 1 deletion(-) create mode 100644 src/Internal/Telemetry/Datadog/DatadogPayload.php create mode 100644 src/Internal/Telemetry/ExportErrors.php create mode 100644 src/Internal/Telemetry/ExportJson.php create mode 100644 src/Internal/Telemetry/JsError.php create mode 100644 src/Internal/Telemetry/Otlp/OtlpPayload.php create mode 100644 tests/Golden/ExporterGoldenTest.php create mode 100644 tests/Support/ExporterFixtures.php create mode 100644 tests/Unit/Internal/Telemetry/Datadog/DatadogPayloadTest.php create mode 100644 tests/Unit/Internal/Telemetry/ExportErrorsTest.php create mode 100644 tests/Unit/Internal/Telemetry/ExportJsonTest.php create mode 100644 tests/Unit/Internal/Telemetry/Otlp/OtlpPayloadTest.php 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/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 @@ + $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/tests/Golden/ExporterGoldenTest.php b/tests/Golden/ExporterGoldenTest.php new file mode 100644 index 0000000..3f90511 --- /dev/null +++ b/tests/Golden/ExporterGoldenTest.php @@ -0,0 +1,112 @@ + '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)); + } + + 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/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 index eeb5ff2..393efc9 100644 --- a/tests/Support/ExporterGolden.php +++ b/tests/Support/ExporterGolden.php @@ -4,6 +4,8 @@ namespace AgentCat\Tests\Support; +use AgentCat\Internal\Json\Json; + /** Reads `tests/Fixtures/golden/exporters/golden-output.txt` and `golden2-output.txt`. */ final class ExporterGolden { @@ -43,4 +45,81 @@ 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; + } + + /** 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'; + } + + /** @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/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/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/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; + } +} From 61387b233c7caece1c388d9c85192467dfa9a5de Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Sat, 17 Oct 2026 22:55:07 +0000 Subject: [PATCH 11/35] feat: build TypeScript-identical Sentry envelopes and PostHog batches --- .../Telemetry/PostHog/PostHogPayload.php | 183 +++++++ src/Internal/Telemetry/Sentry/SentryDsn.php | 32 ++ .../Telemetry/Sentry/SentryPayload.php | 224 ++++++++ .../Telemetry/Sentry/SentryTransaction.php | 23 + tests/Golden/ExporterGoldenTest.php | 100 ++++ tests/Support/ExporterGolden.php | 28 + .../Telemetry/PostHog/PostHogPayloadTest.php | 471 ++++++++++++++++ .../Telemetry/Sentry/SentryDsnTest.php | 92 ++++ .../Telemetry/Sentry/SentryPayloadTest.php | 506 ++++++++++++++++++ 9 files changed, 1659 insertions(+) create mode 100644 src/Internal/Telemetry/PostHog/PostHogPayload.php create mode 100644 src/Internal/Telemetry/Sentry/SentryDsn.php create mode 100644 src/Internal/Telemetry/Sentry/SentryPayload.php create mode 100644 src/Internal/Telemetry/Sentry/SentryTransaction.php create mode 100644 tests/Unit/Internal/Telemetry/PostHog/PostHogPayloadTest.php create mode 100644 tests/Unit/Internal/Telemetry/Sentry/SentryDsnTest.php create mode 100644 tests/Unit/Internal/Telemetry/Sentry/SentryPayloadTest.php diff --git a/src/Internal/Telemetry/PostHog/PostHogPayload.php b/src/Internal/Telemetry/PostHog/PostHogPayload.php new file mode 100644 index 0000000..c1a311b --- /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->{(string) $key} = $value; + } + } +} diff --git a/src/Internal/Telemetry/Sentry/SentryDsn.php b/src/Internal/Telemetry/Sentry/SentryDsn.php new file mode 100644 index 0000000..b60a21f --- /dev/null +++ b/src/Internal/Telemetry/Sentry/SentryDsn.php @@ -0,0 +1,32 @@ +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 @@ +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); diff --git a/tests/Support/ExporterGolden.php b/tests/Support/ExporterGolden.php index 393efc9..6a50fe3 100644 --- a/tests/Support/ExporterGolden.php +++ b/tests/Support/ExporterGolden.php @@ -98,6 +98,34 @@ 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 { diff --git a/tests/Unit/Internal/Telemetry/PostHog/PostHogPayloadTest.php b/tests/Unit/Internal/Telemetry/PostHog/PostHogPayloadTest.php new file mode 100644 index 0000000..81b34eb --- /dev/null +++ b/tests/Unit/Internal/Telemetry/PostHog/PostHogPayloadTest.php @@ -0,0 +1,471 @@ +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 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/Sentry/SentryDsnTest.php b/tests/Unit/Internal/Telemetry/Sentry/SentryDsnTest.php new file mode 100644 index 0000000..e42ea1b --- /dev/null +++ b/tests/Unit/Internal/Telemetry/Sentry/SentryDsnTest.php @@ -0,0 +1,92 @@ +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); + } + + #[Group('ac-06-33')] + #[DataProvider('invalidDsns')] + public function testAnInvalidDsnThrowsWithTheDsnInTheMessage(string $dsn): void + { + $this->assertSame('Invalid Sentry DSN: ' . $dsn, Thrown::by(\InvalidArgumentException::class, static fn(): \AgentCat\Internal\Telemetry\Sentry\SentryDsn => SentryDsn::parse($dsn))->getMessage()); + $this->assertSame('Invalid Sentry DSN: ' . $dsn, Thrown::by(\InvalidArgumentException::class, static fn(): \AgentCat\Internal\Telemetry\Sentry\SentryPayload => SentryPayload::of(new SentryExporter($dsn)))->getMessage()); + } + + /** @return iterable */ + public static function invalidDsns(): iterable + { + yield 'upper-case key' => ['https://ABC@o1.ingest.sentry.io/42']; + yield 'secret' => ['https://abc:secret@o1.ingest.sentry.io/42']; + yield 'non-numeric project' => ['https://abc@o1.ingest.sentry.io/proj']; + yield 'scheme' => ['ftp://abc@h/1']; + yield 'trailing newline' => ["https://abc@h/42\n"]; + yield 'leading text' => [' https://abc@h/42']; + yield 'no key' => ['https://h/42']; + yield 'no project' => ['https://abc@h/']; + yield 'empty key' => ['https://@h/42']; + yield 'non-hex key' => ['https://xyz@h/42']; + yield 'port without digits' => ['https://abc@h:/42']; + yield 'letters in the port' => ['https://abc@h:80a/42']; + yield 'space in the host' => ['https://abc@h h/42']; + yield 'carriage return in the path' => ["https://abc@h/a\rb/42"]; + yield 'line separator in the path' => ["https://abc@h/a\u{2028}b/42"]; + yield 'paragraph separator in the path' => ["https://abc@h/a\u{2029}b/42"]; + yield 'trailing project text' => ['https://abc@h/42x']; + 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..8791f9b --- /dev/null +++ b/tests/Unit/Internal/Telemetry/Sentry/SentryPayloadTest.php @@ -0,0 +1,506 @@ +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 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; + } +} From 201e3f931340d3e1160d7e6b2ef7a2cd61eb03a4 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Sat, 17 Oct 2026 22:59:50 +0000 Subject: [PATCH 12/35] fix: match JavaScript ASCII classes in the Sentry DSN pattern --- src/Internal/Telemetry/PostHog/PostHogPayload.php | 2 +- src/Internal/Telemetry/Sentry/SentryDsn.php | 2 +- .../Internal/Telemetry/PostHog/PostHogPayloadTest.php | 10 ++++++++++ tests/Unit/Internal/Telemetry/Sentry/SentryDsnTest.php | 3 +++ .../Internal/Telemetry/Sentry/SentryPayloadTest.php | 9 +++++++++ 5 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/Internal/Telemetry/PostHog/PostHogPayload.php b/src/Internal/Telemetry/PostHog/PostHogPayload.php index c1a311b..afca545 100644 --- a/src/Internal/Telemetry/PostHog/PostHogPayload.php +++ b/src/Internal/Telemetry/PostHog/PostHogPayload.php @@ -177,7 +177,7 @@ private function spread(\stdClass $target, mixed $map): void return; } foreach (Values::entries($map) as $key => $value) { - $target->{(string) $key} = $value; + $target->{$key} = $value; } } } diff --git a/src/Internal/Telemetry/Sentry/SentryDsn.php b/src/Internal/Telemetry/Sentry/SentryDsn.php index b60a21f..8cab8fc 100644 --- a/src/Internal/Telemetry/Sentry/SentryDsn.php +++ b/src/Internal/Telemetry/Sentry/SentryDsn.php @@ -12,7 +12,7 @@ */ final readonly class SentryDsn { - private const string PATTERN = '~\A(https?)://([a-f0-9]+)@([\w.-]+)(:\d+)?(/[^\n\r\x{2028}\x{2029}]*)?/(\d+)\z~u'; + private const string PATTERN = '~\A(https?)://([a-f0-9]+)@([A-Za-z0-9_.-]+)(:[0-9]+)?(/[^\n\r\x{2028}\x{2029}]*)?/([0-9]+)\z~u'; private function __construct( public string $endpoint, diff --git a/tests/Unit/Internal/Telemetry/PostHog/PostHogPayloadTest.php b/tests/Unit/Internal/Telemetry/PostHog/PostHogPayloadTest.php index 81b34eb..9fbc683 100644 --- a/tests/Unit/Internal/Telemetry/PostHog/PostHogPayloadTest.php +++ b/tests/Unit/Internal/Telemetry/PostHog/PostHogPayloadTest.php @@ -85,6 +85,16 @@ public function testIntegerKeysAndAnObjectOfPropertiesAreSpread(): void $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 { diff --git a/tests/Unit/Internal/Telemetry/Sentry/SentryDsnTest.php b/tests/Unit/Internal/Telemetry/Sentry/SentryDsnTest.php index e42ea1b..d5e01cb 100644 --- a/tests/Unit/Internal/Telemetry/Sentry/SentryDsnTest.php +++ b/tests/Unit/Internal/Telemetry/Sentry/SentryDsnTest.php @@ -87,6 +87,9 @@ public static function invalidDsns(): iterable yield 'line separator in the path' => ["https://abc@h/a\u{2028}b/42"]; yield 'paragraph separator in the path' => ["https://abc@h/a\u{2029}b/42"]; yield 'trailing project text' => ['https://abc@h/42x']; + yield 'non-ASCII letter in the host' => ['https://abc@é.example.com/42']; + yield 'non-ASCII digits in the project' => ['https://abc@o1.sentry.io/٤٢']; + yield 'non-ASCII digits in the port' => ['https://abc@h:٨٠/42']; yield 'empty' => ['']; } } diff --git a/tests/Unit/Internal/Telemetry/Sentry/SentryPayloadTest.php b/tests/Unit/Internal/Telemetry/Sentry/SentryPayloadTest.php index 8791f9b..2988934 100644 --- a/tests/Unit/Internal/Telemetry/Sentry/SentryPayloadTest.php +++ b/tests/Unit/Internal/Telemetry/Sentry/SentryPayloadTest.php @@ -279,6 +279,15 @@ public function testTagsKeepCustomerKeysUnsanitizedAndSkipBlankFields(): void $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(); From 144c2cc364ffafbbb2685da11b545a9ef988c546 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Sat, 17 Oct 2026 23:16:35 +0000 Subject: [PATCH 13/35] feat: send events to OTLP, Datadog, Sentry and PostHog with TypeScript log lines --- .../Telemetry/Datadog/DatadogExport.php | 49 +++ src/Internal/Telemetry/Exporter.php | 24 ++ src/Internal/Telemetry/ExporterFactory.php | 52 +++ src/Internal/Telemetry/Otlp/OtlpExport.php | 47 +++ .../Telemetry/PostHog/PostHogExport.php | 49 +++ .../Telemetry/Sentry/SentryExport.php | 99 +++++ src/Internal/Telemetry/TelemetryManager.php | 118 ++++++ .../Support/Samples/MissingSleepProperty.php | 15 + .../Internal/Telemetry/DatadogExportTest.php | 128 ++++++ .../Internal/Telemetry/OtlpExportTest.php | 116 ++++++ .../Internal/Telemetry/PostHogExportTest.php | 122 ++++++ .../Internal/Telemetry/SentryExportTest.php | 278 +++++++++++++ .../Telemetry/TelemetryManagerTest.php | 366 ++++++++++++++++++ 13 files changed, 1463 insertions(+) create mode 100644 src/Internal/Telemetry/Datadog/DatadogExport.php create mode 100644 src/Internal/Telemetry/Exporter.php create mode 100644 src/Internal/Telemetry/ExporterFactory.php create mode 100644 src/Internal/Telemetry/Otlp/OtlpExport.php create mode 100644 src/Internal/Telemetry/PostHog/PostHogExport.php create mode 100644 src/Internal/Telemetry/Sentry/SentryExport.php create mode 100644 src/Internal/Telemetry/TelemetryManager.php create mode 100644 tests/Support/Samples/MissingSleepProperty.php create mode 100644 tests/Unit/Internal/Telemetry/DatadogExportTest.php create mode 100644 tests/Unit/Internal/Telemetry/OtlpExportTest.php create mode 100644 tests/Unit/Internal/Telemetry/PostHogExportTest.php create mode 100644 tests/Unit/Internal/Telemetry/SentryExportTest.php create mode 100644 tests/Unit/Internal/Telemetry/TelemetryManagerTest.php 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/Exporter.php b/src/Internal/Telemetry/Exporter.php new file mode 100644 index 0000000..03779a2 --- /dev/null +++ b/src/Internal/Telemetry/Exporter.php @@ -0,0 +1,24 @@ +: `. 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/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/Otlp/OtlpExport.php b/src/Internal/Telemetry/Otlp/OtlpExport.php new file mode 100644 index 0000000..7c3e33d --- /dev/null +++ b/src/Internal/Telemetry/Otlp/OtlpExport.php @@ -0,0 +1,47 @@ +`. + * + * @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/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/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/TelemetryManager.php b/src/Internal/Telemetry/TelemetryManager.php new file mode 100644 index 0000000..c875f77 --- /dev/null +++ b/src/Internal/Telemetry/TelemetryManager.php @@ -0,0 +1,118 @@ + $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/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/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/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/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/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..a6f4d4e --- /dev/null +++ b/tests/Unit/Internal/Telemetry/TelemetryManagerTest.php @@ -0,0 +1,366 @@ +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()); + } + + #[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://ABC@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()); + } +} From 9ae3aef5b3cadaf4c9ef16a352612c3c2144640e Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Sat, 17 Oct 2026 23:35:35 +0000 Subject: [PATCH 14/35] feat: set up exporters in track() first-wins with the TypeScript beacons --- src/AgentCat.php | 4 +- src/Client.php | 18 +- src/Internal/ClientSeams.php | 12 +- src/Testing/AgentCatFake.php | 1 + tests/Integration/ClientExportersTest.php | 274 ++++++++++++++++++++++ tests/Integration/ClientTrackTest.php | 22 ++ tests/Unit/Testing/AgentCatFakeTest.php | 21 ++ 7 files changed, 341 insertions(+), 11 deletions(-) create mode 100644 tests/Integration/ClientExportersTest.php diff --git a/src/AgentCat.php b/src/AgentCat.php index 00492b2..604f22f 100644 --- a/src/AgentCat.php +++ b/src/AgentCat.php @@ -83,8 +83,8 @@ 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; diagnostics batches are not counted); false when the timeout ended first. */ public static function flush(?float $timeoutSeconds = null): bool { diff --git a/src/Client.php b/src/Client.php index 66d83cb..cbfe835 100644 --- a/src/Client.php +++ b/src/Client.php @@ -18,6 +18,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 +40,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 +53,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; } /** @@ -106,20 +110,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 +236,8 @@ 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; diagnostics batches are not counted); false when the timeout ended first. */ public function flush(?float $timeoutSeconds = null): bool { 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/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/Integration/ClientExportersTest.php b/tests/Integration/ClientExportersTest.php new file mode 100644 index 0000000..df806ee --- /dev/null +++ b/tests/Integration/ClientExportersTest.php @@ -0,0 +1,274 @@ +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(); + 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(); + } + } + + 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..582374c 100644 --- a/tests/Integration/ClientTrackTest.php +++ b/tests/Integration/ClientTrackTest.php @@ -6,6 +6,8 @@ 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; @@ -28,6 +30,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; @@ -105,6 +108,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 { diff --git a/tests/Unit/Testing/AgentCatFakeTest.php b/tests/Unit/Testing/AgentCatFakeTest.php index 61e671a..8ac94dd 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,26 @@ 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); + $this->assertSame([], array_values(array_filter($logs, static fn(string $l): bool => str_contains($l, 'Initialized telemetry')))); + } + public function testTelemetryOnlyServersAreRecordedToo(): void { $fake = AgentCat::fake(); From bd1810d52115271eebf25a9c81faa2beb7886245 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Sat, 17 Oct 2026 23:39:09 +0000 Subject: [PATCH 15/35] test: pin the default client's exporter setup and drop an unobservable fake assertion --- tests/Integration/ClientExportersTest.php | 17 +++++++++++++++++ tests/Unit/Testing/AgentCatFakeTest.php | 3 ++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/tests/Integration/ClientExportersTest.php b/tests/Integration/ClientExportersTest.php index df806ee..8195b9a 100644 --- a/tests/Integration/ClientExportersTest.php +++ b/tests/Integration/ClientExportersTest.php @@ -69,6 +69,7 @@ protected function tearDown(): void { TrackedServers::resetIncomplete(); TrackedServers::resetSkipped(); + EventQueue::setGlobal(null); Log::reset(); $this->environment->restore(); } @@ -243,6 +244,22 @@ public function testExportsRunOncePerApiAttempt(): void } } + /** 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)); diff --git a/tests/Unit/Testing/AgentCatFakeTest.php b/tests/Unit/Testing/AgentCatFakeTest.php index 8ac94dd..6c18810 100644 --- a/tests/Unit/Testing/AgentCatFakeTest.php +++ b/tests/Unit/Testing/AgentCatFakeTest.php @@ -330,7 +330,8 @@ public function testExportersAreIgnoredByTheFake(): void $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); - $this->assertSame([], array_values(array_filter($logs, static fn(string $l): bool => str_contains($l, 'Initialized telemetry')))); + // 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 From ab8c75d37ccaac909e795dbdb82a480a2d323331 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Sun, 18 Oct 2026 00:04:34 +0000 Subject: [PATCH 16/35] feat: tee every SDK log line to AgentCat diagnostics with TypeScript opt-out rules --- src/Internal/Constants.php | 3 + src/Internal/Diagnostics/Diagnostics.php | 183 ++++++ .../Diagnostics/DiagnosticsAttributes.php | 88 +++ .../Diagnostics/DiagnosticsEndpoint.php | 30 + .../Diagnostics/DiagnosticsPayload.php | 31 + .../Diagnostics/DiagnosticsSwitch.php | 43 ++ src/Internal/Diagnostics/Severity.php | 28 + src/Internal/Logging/FileLogger.php | 9 +- src/Internal/Logging/Log.php | 23 + src/Internal/Logging/LogLine.php | 31 + tests/Golden/ConstantsTest.php | 2 + tests/Golden/DiagnosticsGoldenTest.php | 79 +++ tests/Support/ExporterGolden.php | 19 + .../Diagnostics/DiagnosticsAttributesTest.php | 148 +++++ .../Diagnostics/DiagnosticsEndpointTest.php | 79 +++ .../Diagnostics/DiagnosticsSwitchTest.php | 81 +++ .../Internal/Diagnostics/DiagnosticsTest.php | 600 ++++++++++++++++++ .../Internal/Diagnostics/SeverityTest.php | 37 ++ tests/Unit/Internal/Logging/LogLineTest.php | 49 ++ tests/Unit/Internal/Logging/LogTest.php | 154 +++++ tests/bootstrap.php | 4 + 21 files changed, 1713 insertions(+), 8 deletions(-) create mode 100644 src/Internal/Diagnostics/Diagnostics.php create mode 100644 src/Internal/Diagnostics/DiagnosticsAttributes.php create mode 100644 src/Internal/Diagnostics/DiagnosticsEndpoint.php create mode 100644 src/Internal/Diagnostics/DiagnosticsPayload.php create mode 100644 src/Internal/Diagnostics/DiagnosticsSwitch.php create mode 100644 src/Internal/Diagnostics/Severity.php create mode 100644 src/Internal/Logging/LogLine.php create mode 100644 tests/Golden/DiagnosticsGoldenTest.php create mode 100644 tests/Unit/Internal/Diagnostics/DiagnosticsAttributesTest.php create mode 100644 tests/Unit/Internal/Diagnostics/DiagnosticsEndpointTest.php create mode 100644 tests/Unit/Internal/Diagnostics/DiagnosticsSwitchTest.php create mode 100644 tests/Unit/Internal/Diagnostics/DiagnosticsTest.php create mode 100644 tests/Unit/Internal/Diagnostics/SeverityTest.php create mode 100644 tests/Unit/Internal/Logging/LogLineTest.php 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/Diagnostics/Diagnostics.php b/src/Internal/Diagnostics/Diagnostics.php new file mode 100644 index 0000000..0ae10e5 --- /dev/null +++ b/src/Internal/Diagnostics/Diagnostics.php @@ -0,0 +1,183 @@ +}> */ + private array $buffer = []; + + /** 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, and a later + * `$disabled = true` after a first `false` 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::$firstDisabled !== null) { + if ($disabled && !self::$firstDisabled && !self::$warned) { + self::$warned = true; + Log::write(self::IGNORED); + } + + return; + } + self::$firstDisabled = $disabled; + $testEnvironment = $inTestEnvironment instanceof \Closure ? $inTestEnvironment() : TestMode::inTestEnvironment(); + if (!DiagnosticsSwitch::enabled($disabled, 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::$firstDisabled = null; + 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)(); + if (count($this->buffer) >= self::MAX_BUFFER) { + array_shift($this->buffer); + } + $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->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())), + 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..8baad2c --- /dev/null +++ b/src/Internal/Diagnostics/DiagnosticsAttributes.php @@ -0,0 +1,88 @@ + the resource attributes of every request + */ + public static function resource(string $projectId): array + { + return self::list([ + $projectId !== '' ? ['agentcat.project_id', $projectId] : ['agentcat.install_id', self::installId((string) gethostname(), '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', (string) getmypid()], + ['os.type', self::osType(PHP_OS_FAMILY)], + ['os.version', php_uname('r')], + ['host.arch', self::arch(php_uname('m'))], + ['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..f0e8490 --- /dev/null +++ b/src/Internal/Diagnostics/DiagnosticsEndpoint.php @@ -0,0 +1,30 @@ + $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..565dde2 --- /dev/null +++ b/src/Internal/Diagnostics/DiagnosticsSwitch.php @@ -0,0 +1,43 @@ + 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/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/Support/ExporterGolden.php b/tests/Support/ExporterGolden.php index 6a50fe3..733b2bf 100644 --- a/tests/Support/ExporterGolden.php +++ b/tests/Support/ExporterGolden.php @@ -92,6 +92,25 @@ public static function requests2(string $section): array 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 { diff --git a/tests/Unit/Internal/Diagnostics/DiagnosticsAttributesTest.php b/tests/Unit/Internal/Diagnostics/DiagnosticsAttributesTest.php new file mode 100644 index 0000000..4ae5d19 --- /dev/null +++ b/tests/Unit/Internal/Diagnostics/DiagnosticsAttributesTest.php @@ -0,0 +1,148 @@ +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'))); + } + + public function testEveryResourceValueIsANonEmptyString(): void + { + foreach (DiagnosticsAttributes::resource('proj_x') as $attribute) { + $this->assertNotSame('', $attribute['value']['stringValue'], $attribute['key']); + } + } + + 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..6146cd7 --- /dev/null +++ b/tests/Unit/Internal/Diagnostics/DiagnosticsEndpointTest.php @@ -0,0 +1,79 @@ +environment = Environment::clear('DIAGNOSTICS_ENDPOINT', 'DIAGNOSTICS_TOKEN'); + putenv('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('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('DIAGNOSTICS_ENDPOINT=' . $base); + + $this->assertSame($expected, DiagnosticsEndpoint::url()); + } + + public function testTheVariableIsReadFromTheServerArrayToo(): void + { + putenv('DIAGNOSTICS_ENDPOINT'); + $_SERVER['DIAGNOSTICS_ENDPOINT'] = 'https://fpm.example'; + $_SERVER['DIAGNOSTICS_TOKEN'] = 'fpm'; + + $this->assertSame('https://fpm.example/v1/logs', DiagnosticsEndpoint::url()); + $this->assertSame('fpm', DiagnosticsEndpoint::token()); + } + + #[Group('ac-06-55')] + public function testTheTokenVariableReplacesTheDefaultUnlessItIsEmpty(): void + { + putenv('DIAGNOSTICS_TOKEN=t'); + $this->assertSame('t', DiagnosticsEndpoint::token()); + + putenv('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..70e0e42 --- /dev/null +++ b/tests/Unit/Internal/Diagnostics/DiagnosticsSwitchTest.php @@ -0,0 +1,81 @@ +assertTrue(DiagnosticsSwitch::enabled(false, 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, false)); + $this->assertFalse(DiagnosticsSwitch::enabled(false, $value, 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, true)); + $this->assertTrue(DiagnosticsSwitch::enabled(false, $value, 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, false)); + $this->assertFalse(DiagnosticsSwitch::enabled(false, $value, true)); + } + + #[Group('ac-06-48')] + public function testTheOptionWinsOverTheVariable(): void + { + $this->assertFalse(DiagnosticsSwitch::enabled(true, 'false', false)); + $this->assertFalse(DiagnosticsSwitch::enabled(true, null, false)); + } +} diff --git a/tests/Unit/Internal/Diagnostics/DiagnosticsTest.php b/tests/Unit/Internal/Diagnostics/DiagnosticsTest.php new file mode 100644 index 0000000..bced9a3 --- /dev/null +++ b/tests/Unit/Internal/Diagnostics/DiagnosticsTest.php @@ -0,0 +1,600 @@ +environment = Environment::clear('DISABLE_DIAGNOSTICS', 'DIAGNOSTICS_ENDPOINT', 'DIAGNOSTICS_TOKEN', 'APP_ENV'); + putenv('DIAGNOSTICS_ENDPOINT=' . MockCollector::closedPortUrl()); // fail closed (ruling P8) + 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('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)); + } + + /** 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()); + } + + #[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(): 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(): int + { + return $this->inner->pending(); + } + + #[\Override] + public function waitForActivity(float $seconds): void + { + $this->inner->waitForActivity($seconds); + } + }; + + try { + putenv('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 'DISABLE_DIAGNOSTICS=false' => ['false']; + } + + #[DataProvider('anyVariable')] + #[Group('ac-06-48')] + #[Group('ac-01-61')] + public function testTheDisableOptionBuffersAndSendsNothing(?string $variable): void + { + if ($variable !== null) { + putenv('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('DISABLE_DIAGNOSTICS=true'); + $this->init('p'); + Log::write('x'); + + $this->assertFalse(Diagnostics::enabled()); + $this->assertFalse($this->queue->hasWork()); + } + + #[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'); + } + + #[Group('ac-06-52')] + #[Group('ac-01-65')] + public function testATestEnvironmentTurnsDiagnosticsOffUnlessTheVariableIsFalse(): void + { + $this->init('p', testEnvironment: true); + $this->assertFalse(Diagnostics::enabled()); + + Diagnostics::reset(); + putenv('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('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('DIAGNOSTICS_ENDPOINT=https://c.example/'); + putenv('DIAGNOSTICS_TOKEN=t'); + Log::write('one'); + $this->now += 2.0; + $this->queue->pump(); + + putenv('DIAGNOSTICS_ENDPOINT=https://c.example/v1/logs//'); + putenv('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/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/bootstrap.php b/tests/bootstrap.php index 4318307..a4de75d 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -15,6 +15,10 @@ putenv('HOME=' . $agentcatTestHome); $_SERVER['HOME'] = $_ENV['HOME'] = $agentcatTestHome; +// TS `src/tests/setup.ts`: no test sends diagnostics unless it clears this; subprocesses inherit it. +putenv('DISABLE_DIAGNOSTICS=true'); +$_SERVER['DISABLE_DIAGNOSTICS'] = $_ENV['DISABLE_DIAGNOSTICS'] = 'true'; + register_shutdown_function(static function () use ($agentcatTestHome): void { $names = scandir($agentcatTestHome); foreach ($names === false ? [] : $names as $name) { From 7d5f92f93a9f22db8fb8284f97d2074f0ce02d74 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Sun, 18 Oct 2026 00:21:55 +0000 Subject: [PATCH 17/35] fix: keep diagnostics on hardened hosts and warn only when they started --- src/Internal/Diagnostics/Diagnostics.php | 18 ++--- .../Diagnostics/DiagnosticsAttributes.php | 19 +++-- .../Diagnostics/HardenedHostTest.php | 77 +++++++++++++++++++ .../Fixtures/diagnostics-hardened.php | 26 +++++++ .../Diagnostics/DiagnosticsAttributesTest.php | 48 +++++++++++- .../Internal/Diagnostics/DiagnosticsTest.php | 17 ++++ 6 files changed, 187 insertions(+), 18 deletions(-) create mode 100644 tests/Integration/Diagnostics/HardenedHostTest.php create mode 100644 tests/Integration/Fixtures/diagnostics-hardened.php diff --git a/src/Internal/Diagnostics/Diagnostics.php b/src/Internal/Diagnostics/Diagnostics.php index 0ae10e5..ba24244 100644 --- a/src/Internal/Diagnostics/Diagnostics.php +++ b/src/Internal/Diagnostics/Diagnostics.php @@ -17,7 +17,8 @@ * the process decides. Lines are buffered (1 000 at most, the oldest dropped) and posted at the first queue pass * (`pump()`, `flush()`, `shutdown()`) once the oldest of them is 2 s old, and once more when the queue shuts down, after * the events (D47: under PHP-FPM one post per request, after the response). Lines logged in the last shutdown pass, - * after `finish()`, wait for a later `shutdown()`. A failure is silent: a log line would feed the buffer it reports on. + * after `finish()`, are dropped unless a later `shutdown()` runs. A failure is silent: a log line would feed the buffer + * it reports on. * * @internal */ @@ -29,8 +30,7 @@ final class Diagnostics implements QueueCompanion private const string IGNORED = 'WARN: AgentCat diagnostics were set up by an earlier track(); disableDiagnostics: true in this call is ignored'; - /** The `disableDiagnostics` option of the first call; null before it. */ - private static ?bool $firstDisabled = null; + private static bool $initialized = false; private static bool $warned = false; @@ -59,8 +59,8 @@ private function __construct( ) {} /** - * TS `initDiagnostics`: the first call in the process decides; a later call changes nothing, and a later - * `$disabled = true` after a first `false` logs one line, once. Never throws. + * 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 @@ -69,15 +69,15 @@ private function __construct( public static function init(string $projectId, bool $disabled, EventQueue $queue, ?\Closure $inTestEnvironment = null, ?\Closure $clock = null, ?\Closure $wallMillis = null): void { try { - if (self::$firstDisabled !== null) { - if ($disabled && !self::$firstDisabled && !self::$warned) { + if (self::$initialized) { + if ($disabled && self::$active instanceof self && !self::$warned) { self::$warned = true; Log::write(self::IGNORED); } return; } - self::$firstDisabled = $disabled; + self::$initialized = true; $testEnvironment = $inTestEnvironment instanceof \Closure ? $inTestEnvironment() : TestMode::inTestEnvironment(); if (!DiagnosticsSwitch::enabled($disabled, Env::get('DISABLE_DIAGNOSTICS'), $testEnvironment)) { return; @@ -105,7 +105,7 @@ public static function enabled(): bool /** Tests only: forgets the decision and removes the sink. */ public static function reset(): void { - self::$firstDisabled = null; + self::$initialized = false; self::$warned = false; self::$active = null; Log::setDiagnosticsSink(null); diff --git a/src/Internal/Diagnostics/DiagnosticsAttributes.php b/src/Internal/Diagnostics/DiagnosticsAttributes.php index 8baad2c..d753233 100644 --- a/src/Internal/Diagnostics/DiagnosticsAttributes.php +++ b/src/Internal/Diagnostics/DiagnosticsAttributes.php @@ -16,23 +16,30 @@ final class DiagnosticsAttributes { /** * Without a project, `agentcat.install_id` hashes the host name and `file://` + the package root (`__DIR__` has its - * symlinks resolved). + * symlinks resolved). A host function in `disable_functions` (`getmypid`, `php_uname`) leaves out only its + * attribute; without `gethostname` the host name is empty (TS `os.hostname?.() ?? ""`). + * + * @param (\Closure(string): bool)|null $functionExists tests only: tells whether a host function can be called; default `function_exists()` * * @return list the resource attributes of every request */ - public static function resource(string $projectId): array + 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((string) gethostname(), 'file://' . dirname(__DIR__, 3))], + $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', (string) getmypid()], + ['process.pid', $can('getmypid') ? (string) getmypid() : null], ['os.type', self::osType(PHP_OS_FAMILY)], - ['os.version', php_uname('r')], - ['host.arch', self::arch(php_uname('m'))], + ['os.version', $uname ? php_uname('r') : null], + ['host.arch', $uname ? self::arch(php_uname('m')) : null], ['deployment.environment', Env::get('APP_ENV')], ]); } diff --git a/tests/Integration/Diagnostics/HardenedHostTest.php b/tests/Integration/Diagnostics/HardenedHostTest.php new file mode 100644 index 0000000..5c31137 --- /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, 'DIAGNOSTICS_ENDPOINT' => $collector, 'DISABLE_DIAGNOSTICS' => false, '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/Unit/Internal/Diagnostics/DiagnosticsAttributesTest.php b/tests/Unit/Internal/Diagnostics/DiagnosticsAttributesTest.php index 4ae5d19..dbbf60f 100644 --- a/tests/Unit/Internal/Diagnostics/DiagnosticsAttributesTest.php +++ b/tests/Unit/Internal/Diagnostics/DiagnosticsAttributesTest.php @@ -5,8 +5,10 @@ namespace AgentCat\Tests\Unit\Internal\Diagnostics; use AgentCat\Internal\Diagnostics\DiagnosticsAttributes; +use AgentCat\Internal\Json\Json; use AgentCat\Internal\SdkVersion; use AgentCat\Tests\Support\Environment; +use AgentCat\Tests\Support\JsonAssert; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; @@ -48,13 +50,53 @@ public function testTheResourceOfAProjectNamesTheProjectSdkRuntimeAndHost(): voi ], $this->pairs(DiagnosticsAttributes::resource('proj_x'))); } - public function testEveryResourceValueIsANonEmptyString(): void + /** + * 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 { - foreach (DiagnosticsAttributes::resource('proj_x') as $attribute) { - $this->assertNotSame('', $attribute['value']['stringValue'], $attribute['key']); + $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'); diff --git a/tests/Unit/Internal/Diagnostics/DiagnosticsTest.php b/tests/Unit/Internal/Diagnostics/DiagnosticsTest.php index bced9a3..73ca455 100644 --- a/tests/Unit/Internal/Diagnostics/DiagnosticsTest.php +++ b/tests/Unit/Internal/Diagnostics/DiagnosticsTest.php @@ -337,6 +337,23 @@ public function testTheFirstInitDecides(): void $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('DISABLE_DIAGNOSTICS=true'); + $this->init('p'); + $this->init('p', disabled: true); + + Diagnostics::reset(); + putenv('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 From b3ef409c54b500b745952e9781f5856a189bed4b Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Sun, 18 Oct 2026 00:34:40 +0000 Subject: [PATCH 18/35] feat: set diagnostics up in track() and flush them at the end of each process --- src/Client.php | 5 + tests/E2E/Fixtures/http-server.php | 8 + tests/E2E/HttpEndToEndTest.php | 64 ++++++ tests/E2E/StdioEndToEndTest.php | 45 +++- tests/Integration/ClientDiagnosticsTest.php | 232 ++++++++++++++++++++ 5 files changed, 351 insertions(+), 3 deletions(-) create mode 100644 tests/Integration/ClientDiagnosticsTest.php diff --git a/src/Client.php b/src/Client.php index cbfe835..829eedf 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; @@ -86,6 +87,10 @@ 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 (!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)); diff --git a/tests/E2E/Fixtures/http-server.php b/tests/E2E/Fixtures/http-server.php index 589dc19..6fe1b41 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 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('DIAGNOSTICS_ENDPOINT=' . $diagnostics); + putenv('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..f1603b0 100644 --- a/tests/E2E/HttpEndToEndTest.php +++ b/tests/E2E/HttpEndToEndTest.php @@ -26,6 +26,9 @@ 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 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 +68,7 @@ public static function setUpBeforeClass(): void self::$server = BuiltInServer::start(__DIR__ . '/Fixtures/http-server.php', [ 'AGENTCAT_ENABLED' => 'true', 'AGENTCAT_API_URL' => self::$fallbackUrl, + '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 +283,35 @@ 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 the response. A slow runner can add a request without the per-event line, from a 2 s window inside a + * request. + */ + #[Group('ac-06-62')] + #[MaximumDuration(15000)] + public function testEachRequestSendsItsOwnDiagnosticsAfterTheResponse(): void + { + $diagnostics = $this->collector()->scenario(); + $client = $this->client(); + $client->initialize(); + + $first = $client->callTool('echo', ['msg' => 'one', 'session_id' => 'start'], $this->headers(['X-E2E-Diagnostics' => $diagnostics])); + $second = $client->callTool('echo', ['msg' => 'two', 'session_id' => 'start'], $this->headers(['X-E2E-Diagnostics' => $diagnostics])); + + $this->assertSame('echo: one', JsonAssert::at(HttpHarness::result($first['message']), 'content', 1, 'text')); + $this->assertSame('echo: two', JsonAssert::at(HttpHarness::result($second['message']), 'content', 1, 'text')); + $sent = $this->diagnosticsWithASentEvent($diagnostics, 2); + $this->assertCount(2, $sent, 'one diagnostics request with the per-event line per tracked request'); + foreach ($sent as $request) { + $this->assertSame('/v1/logs', $request->path); + $this->assertSame(1, substr_count($this->body($request), 'Successfully sent event '), 'one line each'); + } + $starts = array_map(static fn(\stdClass $request): mixed => $request->start, $sent); + $this->assertIsFloat(min($starts)); + $this->assertGreaterThan($first['receivedAt'], min($starts), 'the diagnostics POST ran after the 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 +367,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 { diff --git a/tests/E2E/StdioEndToEndTest.php b/tests/E2E/StdioEndToEndTest.php index cde2ded..197deb2 100644 --- a/tests/E2E/StdioEndToEndTest.php +++ b/tests/E2E/StdioEndToEndTest.php @@ -241,6 +241,32 @@ public function testSigtermDeliversTheQueueAndThenEndsTheProcessBySignal(): void $this->assertStdoutIsOnlyJsonRpc($client); } + /** 06 AC-62 in a real process: stdin EOF flushes the diagnostics buffer once, with the setup beacons. */ + #[Group('ac-06-62')] + #[MaximumDuration(15000)] + public function testStdinEofSendsTheDiagnosticsOnce(): void + { + $diagnostics = $this->collector()->scenario(); + $client = $this->start(env: ['DISABLE_DIAGNOSTICS' => 'false', 'DIAGNOSTICS_ENDPOINT' => $diagnostics]); + $this->handshake($client); + $this->resultOf($client, 'tools/call', ['name' => 'echo', 'arguments' => ['msg' => 'one', 'session_id' => 'start']]); + + $this->assertExitsAtEof($client); + + // A slow runner can pass the 2 s window during the session and post once more at a handler-entry pump: + // the last request is the shutdown flush, and it carries the per-event line logged during the EOF drain. + $requests = $this->collector()->requests($diagnostics); + $this->assertNotSame([], $requests); + $bodies = array_map($this->body(...), $requests); + $all = implode("\n", $bodies); + foreach ($requests as $request) { + $this->assertSame('/v1/logs', $request->path); + } + $this->assertStringContainsString('"stringValue":"php"', $all); + $this->assertStringContainsString('AgentCat setup complete | project proj_', $all); + $this->assertStringContainsString('Successfully sent event ', $bodies[count($bodies) - 1]); + } + /** 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 +291,14 @@ 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 `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 `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 +306,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(), + 'DIAGNOSTICS_ENDPOINT' => MockCollector::closedPortUrl(), 'AGENTCAT_E2E_COLLECTOR' => $this->scenario, 'HOME' => $this->home, + ...$env, ]); return $this->client; @@ -387,6 +417,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/Integration/ClientDiagnosticsTest.php b/tests/Integration/ClientDiagnosticsTest.php new file mode 100644 index 0000000..c589d25 --- /dev/null +++ b/tests/Integration/ClientDiagnosticsTest.php @@ -0,0 +1,232 @@ +environment = Environment::clear('DISABLE_DIAGNOSTICS', 'DIAGNOSTICS_ENDPOINT', 'DIAGNOSTICS_TOKEN', 'AGENTCAT_PROJECT_ID', 'AGENTCAT_API_URL', 'MCPCAT_API_URL', 'AGENTCAT_ENABLED', 'APP_ENV'); + $this->endpoint = MockCollector::closedPortUrl(); + putenv('DIAGNOSTICS_ENDPOINT=' . $this->endpoint); // fail closed: never otel.agentcat.com + 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('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('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('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('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('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('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('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; + } +} From 3dcdb28bbeb43657ff10bdfab7ef4f185975c571 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Sun, 18 Oct 2026 00:44:19 +0000 Subject: [PATCH 19/35] test: count the stdio diagnostics POSTs and tie each HTTP request to its own POST --- tests/E2E/HttpEndToEndTest.php | 49 ++++++++++++++++++++++++--------- tests/E2E/StdioEndToEndTest.php | 28 +++++++++++-------- 2 files changed, 52 insertions(+), 25 deletions(-) diff --git a/tests/E2E/HttpEndToEndTest.php b/tests/E2E/HttpEndToEndTest.php index f1603b0..cfecbb4 100644 --- a/tests/E2E/HttpEndToEndTest.php +++ b/tests/E2E/HttpEndToEndTest.php @@ -29,6 +29,12 @@ final class HttpEndToEndTest extends TestCase /** 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; @@ -285,31 +291,38 @@ public function testOddHeadersReachTheEventAsStrings(): void /** * D47 (open question Q3): under `php -S`, like PHP-FPM, each request posts its own diagnostics once, at its end, - * after the response. A slow runner can add a request without the per-event line, from a 2 s window inside a - * request. + * 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(); - $first = $client->callTool('echo', ['msg' => 'one', 'session_id' => 'start'], $this->headers(['X-E2E-Diagnostics' => $diagnostics])); - $second = $client->callTool('echo', ['msg' => 'two', 'session_id' => 'start'], $this->headers(['X-E2E-Diagnostics' => $diagnostics])); + $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->assertSame('echo: one', JsonAssert::at(HttpHarness::result($first['message']), 'content', 1, 'text')); - $this->assertSame('echo: two', JsonAssert::at(HttpHarness::result($second['message']), 'content', 1, 'text')); + $this->assertCount(2, $answeredAt, 'each call minted its own session'); $sent = $this->diagnosticsWithASentEvent($diagnostics, 2); - $this->assertCount(2, $sent, 'one diagnostics request with the per-event line per tracked request'); - foreach ($sent as $request) { - $this->assertSame('/v1/logs', $request->path); - $this->assertSame(1, substr_count($this->body($request), 'Successfully sent event '), 'one line each'); + $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'); } - $starts = array_map(static fn(\stdClass $request): mixed => $request->start, $sent); - $this->assertIsFloat(min($starts)); - $this->assertGreaterThan($first['receivedAt'], min($starts), 'the diagnostics POST ran after the response'); } /** A call without `X-E2E-Collector` falls back to `AGENTCAT_API_URL`, a closed local port: never the default API. */ @@ -428,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 197deb2..6133c9a 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,30 +242,33 @@ public function testSigtermDeliversTheQueueAndThenEndsTheProcessBySignal(): void $this->assertStdoutIsOnlyJsonRpc($client); } - /** 06 AC-62 in a real process: stdin EOF flushes the diagnostics buffer once, with the setup beacons. */ + /** + * 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: ['DISABLE_DIAGNOSTICS' => 'false', 'DIAGNOSTICS_ENDPOINT' => $diagnostics]); $this->handshake($client); $this->resultOf($client, 'tools/call', ['name' => 'echo', 'arguments' => ['msg' => 'one', 'session_id' => 'start']]); $this->assertExitsAtEof($client); - // A slow runner can pass the 2 s window during the session and post once more at a handler-entry pump: - // the last request is the shutdown flush, and it carries the per-event line logged during the EOF drain. + // 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->assertNotSame([], $requests); - $bodies = array_map($this->body(...), $requests); - $all = implode("\n", $bodies); - foreach ($requests as $request) { - $this->assertSame('/v1/logs', $request->path); - } - $this->assertStringContainsString('"stringValue":"php"', $all); - $this->assertStringContainsString('AgentCat setup complete | project proj_', $all); - $this->assertStringContainsString('Successfully sent event ', $bodies[count($bodies) - 1]); + $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. */ From e9ef275f0b2b9927d4aa3f108f0f4bc2490c5e52 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Sun, 18 Oct 2026 00:51:55 +0000 Subject: [PATCH 20/35] feat: add disableSignalHandlers to keep AgentCat's signal handlers out of a process --- src/Client.php | 3 + src/Internal/Delivery/EventQueue.php | 10 +++- src/Internal/Delivery/SignalDrain.php | 15 ++++- tests/Integration/ClientTrackTest.php | 58 +++++++++++++++++++ .../Delivery/SignalDrainDeliveryTest.php | 24 ++++++++ tests/Integration/Fixtures/signal-drain.php | 5 ++ .../Unit/Internal/Delivery/EventQueueTest.php | 29 ++++++++++ .../Internal/Delivery/SignalDrainTest.php | 23 ++++++++ 8 files changed, 165 insertions(+), 2 deletions(-) diff --git a/src/Client.php b/src/Client.php index 829eedf..45b1477 100644 --- a/src/Client.php +++ b/src/Client.php @@ -91,6 +91,9 @@ public function track(object $server, ?string $projectId = null, ?Options $optio // 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()) { + 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)); diff --git a/src/Internal/Delivery/EventQueue.php b/src/Internal/Delivery/EventQueue.php index 1af6d20..a4049f0 100644 --- a/src/Internal/Delivery/EventQueue.php +++ b/src/Internal/Delivery/EventQueue.php @@ -74,6 +74,7 @@ final class EventQueue * @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(), @@ -83,6 +84,7 @@ public function __construct( 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(...); @@ -101,12 +103,18 @@ 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; + } + /** Tests only: replaces the global queue; null makes `global()` build a new one. */ public static function setGlobal(?self $queue): void { 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/tests/Integration/ClientTrackTest.php b/tests/Integration/ClientTrackTest.php index 582374c..b11de6b 100644 --- a/tests/Integration/ClientTrackTest.php +++ b/tests/Integration/ClientTrackTest.php @@ -12,6 +12,7 @@ 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; @@ -38,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; @@ -53,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; @@ -67,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(); @@ -84,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 testTrackWarnsOncePerCallWhenTheSignalHandlersAreInstalledAlready(): 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(2, 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')] @@ -611,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/SignalDrainDeliveryTest.php b/tests/Integration/Delivery/SignalDrainDeliveryTest.php index 270ffcd..b614849 100644 --- a/tests/Integration/Delivery/SignalDrainDeliveryTest.php +++ b/tests/Integration/Delivery/SignalDrainDeliveryTest.php @@ -91,6 +91,30 @@ 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)); + } + #[Group('ac-03-11')] #[MaximumDuration(10000)] public function testWithACustomerHandlerTheQueueDrainsThenTheHandlerRunsAndTheProcessGoesOn(): void diff --git a/tests/Integration/Fixtures/signal-drain.php b/tests/Integration/Fixtures/signal-drain.php index 5b130dc..b5f230d 100644 --- a/tests/Integration/Fixtures/signal-drain.php +++ b/tests/Integration/Fixtures/signal-drain.php @@ -19,6 +19,8 @@ // ; 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. // 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,6 +36,9 @@ }); } $queue = EventQueue::global(); +if ($mode === 'disabled') { + $queue->disableSignalHandlers(); +} $queue->configure($baseUrl); $queue->add(new QueuedEvent(new Event(projectId: 'proj_test', eventType: 'mcp:tools/call', resourceName: 'signal-drain'))); if ($mode === 'shutdown') { diff --git a/tests/Unit/Internal/Delivery/EventQueueTest.php b/tests/Unit/Internal/Delivery/EventQueueTest.php index 3d481ca..b038178 100644 --- a/tests/Unit/Internal/Delivery/EventQueueTest.php +++ b/tests/Unit/Internal/Delivery/EventQueueTest.php @@ -7,6 +7,7 @@ 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; @@ -27,6 +28,7 @@ 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; @@ -1200,4 +1202,31 @@ public function export(Event $event): 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/SignalDrainTest.php b/tests/Unit/Internal/Delivery/SignalDrainTest.php index dc71a06..2f5465a 100644 --- a/tests/Unit/Internal/Delivery/SignalDrainTest.php +++ b/tests/Unit/Internal/Delivery/SignalDrainTest.php @@ -311,4 +311,27 @@ 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); + $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(\SIG_DFL, pcntl_signal_get_handler(\SIGINT)); + } + + #[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)); + } } From b4b994d221e07703fc9ab2e15a84018e01d812fb Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Sun, 18 Oct 2026 00:58:57 +0000 Subject: [PATCH 21/35] fix: log the ignored disableSignalHandlers warning once per process Document the switch's timing and first-wins rule, and cover the SIGINT handler, async-signal mode and shutdown-function delivery. --- src/Client.php | 2 +- src/Internal/Delivery/EventQueue.php | 11 +++++++++++ src/Options.php | 7 +++++-- tests/Integration/ClientTrackTest.php | 4 ++-- .../Delivery/SignalDrainDeliveryTest.php | 13 +++++++++++++ tests/Integration/Fixtures/signal-drain.php | 5 +++-- tests/Unit/Internal/Delivery/SignalDrainTest.php | 5 ++++- 7 files changed, 39 insertions(+), 8 deletions(-) diff --git a/src/Client.php b/src/Client.php index 45b1477..bdf794f 100644 --- a/src/Client.php +++ b/src/Client.php @@ -91,7 +91,7 @@ public function track(object $server, ?string $projectId = null, ?Options $optio // 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()) { + 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)) { diff --git a/src/Internal/Delivery/EventQueue.php b/src/Internal/Delivery/EventQueue.php index a4049f0..a15b0d8 100644 --- a/src/Internal/Delivery/EventQueue.php +++ b/src/Internal/Delivery/EventQueue.php @@ -61,6 +61,8 @@ 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; @@ -115,6 +117,15 @@ 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 { diff --git a/src/Options.php b/src/Options.php index 68d52e6..8be2ed3 100644 --- a/src/Options.php +++ b/src/Options.php @@ -44,8 +44,11 @@ * AgentCat to find broken installs. The lines can contain session IDs, actor IDs, tool names and error messages. * The first track() in the process decides. DISABLE_DIAGNOSTICS also turns them off, or on with false; they are * off in test environments. - * @param bool $disableSignalHandlers Does not install the SIGTERM and SIGINT handlers that deliver queued events before - * the process ends. Use it when your application installs its own handlers. Default: false. + * @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, diff --git a/tests/Integration/ClientTrackTest.php b/tests/Integration/ClientTrackTest.php index b11de6b..9a540de 100644 --- a/tests/Integration/ClientTrackTest.php +++ b/tests/Integration/ClientTrackTest.php @@ -112,7 +112,7 @@ protected function tearDown(): void } #[RequiresPhpExtension('pcntl')] - public function testTrackWarnsOncePerCallWhenTheSignalHandlersAreInstalledAlready(): void + public function testTrackWarnsOncePerProcessWhenTheSignalHandlersAreInstalledAlready(): void { $queue = new EventQueue(transport: $this->transport, signalDrain: $this->installedDrain()); $client = new Client(ClientSeams::of($queue, static fn(): bool => true)); @@ -121,7 +121,7 @@ public function testTrackWarnsOncePerCallWhenTheSignalHandlersAreInstalledAlread $client->track(TrackingFixture::builder(), self::PROJECT, new Options(disableSignalHandlers: true)); $client->track(TrackingFixture::builder(), self::PROJECT); - $this->assertCount(2, array_keys($this->logger->messages(), self::SIGNALS_INSTALLED, true)); + $this->assertCount(1, array_keys($this->logger->messages(), self::SIGNALS_INSTALLED, true)); } #[RequiresPhpExtension('pcntl')] diff --git a/tests/Integration/Delivery/SignalDrainDeliveryTest.php b/tests/Integration/Delivery/SignalDrainDeliveryTest.php index b614849..d896a21 100644 --- a/tests/Integration/Delivery/SignalDrainDeliveryTest.php +++ b/tests/Integration/Delivery/SignalDrainDeliveryTest.php @@ -115,6 +115,19 @@ public function testWithTheHandlersDisabledSigtermEndsTheProcessAtOnceAndNothing $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/Fixtures/signal-drain.php b/tests/Integration/Fixtures/signal-drain.php index b5f230d..fa30d9d 100644 --- a/tests/Integration/Fixtures/signal-drain.php +++ b/tests/Integration/Fixtures/signal-drain.php @@ -21,6 +21,7 @@ // 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] ?? ''; @@ -36,12 +37,12 @@ }); } $queue = EventQueue::global(); -if ($mode === 'disabled') { +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/Unit/Internal/Delivery/SignalDrainTest.php b/tests/Unit/Internal/Delivery/SignalDrainTest.php index 2f5465a..03d6f73 100644 --- a/tests/Unit/Internal/Delivery/SignalDrainTest.php +++ b/tests/Unit/Internal/Delivery/SignalDrainTest.php @@ -316,13 +316,16 @@ private function queued(string $name): QueuedEvent 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(\SIG_DFL, pcntl_signal_get_handler(\SIGINT)); + $this->assertSame($beforeInt, pcntl_signal_get_handler(\SIGINT)); + $this->assertSame($async, pcntl_async_signals(), 'the switch leaves the async-signal mode alone'); } #[RequiresPhpExtension('pcntl')] From 8007d418db4582f39d97800e6162bb7a5b8062af Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Sun, 18 Oct 2026 02:09:21 +0000 Subject: [PATCH 22/35] fix: keep AgentCat's diagnostics requests out of flush() 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. --- src/AgentCat.php | 3 +- src/Client.php | 3 +- src/Internal/Delivery/EventQueue.php | 32 +++++---- src/Internal/Delivery/QueueCompanion.php | 3 +- src/Internal/Diagnostics/Diagnostics.php | 12 ++-- src/Internal/Transport/CurlHttpChannel.php | 17 ++++- src/Internal/Transport/CurlMulti.php | 6 ++ src/Internal/Transport/HttpChannel.php | 4 +- src/Internal/Transport/HttpRequest.php | 7 +- src/Internal/Transport/HttpTransfer.php | 10 ++- .../Transport/CurlHttpChannelTest.php | 17 +++++ tests/Support/RecordingHttpChannel.php | 15 ++-- .../Unit/Internal/Delivery/EventQueueTest.php | 69 +++++++++++++++++++ .../Internal/Diagnostics/DiagnosticsTest.php | 43 +++++++++++- .../Transport/CurlHttpChannelUnitTest.php | 15 ++++ 15 files changed, 217 insertions(+), 39 deletions(-) diff --git a/src/AgentCat.php b/src/AgentCat.php index 604f22f..3cefd4a 100644 --- a/src/AgentCat.php +++ b/src/AgentCat.php @@ -84,7 +84,8 @@ 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, and no - * exporter request left; diagnostics batches are not counted); false when the timeout ended first. + * 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 bdf794f..0fa2eb3 100644 --- a/src/Client.php +++ b/src/Client.php @@ -245,7 +245,8 @@ 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, and no - * exporter request left; diagnostics batches are not counted); false when the timeout ended first. + * 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/Internal/Delivery/EventQueue.php b/src/Internal/Delivery/EventQueue.php index a15b0d8..b3223d7 100644 --- a/src/Internal/Delivery/EventQueue.php +++ b/src/Internal/Delivery/EventQueue.php @@ -25,8 +25,8 @@ * 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()` - * and `shutdown()` wait for the channel inside the same timeout. + * (`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 */ @@ -226,20 +226,21 @@ public function pump(): void } /** - * Blocking: delivers the events and the side channel's exporter and diagnostics requests until no event waits - * or is in flight and no side request is pending (true), or the timeout passes (false); null = no limit. A + * 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 the events and the side requests 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 @@ -326,13 +327,14 @@ private function finishReserve(): float } /** - * The delivery loop of `flush()` and `shutdown()`: done when no event waits or is in flight and the side channel is - * idle. A re-entrant call returns false at once. + * 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 \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 + private function deliverUntil(\Closure $deadline, bool $tick = true, bool $background = true): bool { if ($this->busy) { return false; @@ -343,7 +345,7 @@ private function deliverUntil(\Closure $deadline, bool $tick = true): bool $until = $deadline(); while (true) { $this->pumpOnce($tick); - if ($this->waiting->isEmpty() && $this->transport->inFlight() === 0 && $this->channel->pending() === 0) { + if ($this->waiting->isEmpty() && $this->transport->inFlight() === 0 && $this->channel->pending($background) === 0) { return true; } if ($this->transport->now() >= $until) { diff --git a/src/Internal/Delivery/QueueCompanion.php b/src/Internal/Delivery/QueueCompanion.php index e8eb673..183989b 100644 --- a/src/Internal/Delivery/QueueCompanion.php +++ b/src/Internal/Delivery/QueueCompanion.php @@ -6,7 +6,8 @@ /** * Work that runs beside the events in the queue's passes (diagnostics): `EventQueue::attach()` adds one. Its HTTP - * requests go through `EventQueue::channel()`. A thrown error is swallowed and never stops the queue. + * requests go through `EventQueue::channel()`, as background requests when `flush()` must not wait for them + * (`HttpRequest::$background`). A thrown error is swallowed and never stops the queue. * * @internal */ diff --git a/src/Internal/Diagnostics/Diagnostics.php b/src/Internal/Diagnostics/Diagnostics.php index ba24244..a08d6c1 100644 --- a/src/Internal/Diagnostics/Diagnostics.php +++ b/src/Internal/Diagnostics/Diagnostics.php @@ -14,11 +14,11 @@ /** * TypeScript diagnostics: every SDK log line (D07), as OTLP/JSON logs to AgentCat's collector. The first `init()` of - * the process decides. Lines are buffered (1 000 at most, the oldest dropped) and posted at the first queue pass - * (`pump()`, `flush()`, `shutdown()`) once the oldest of them is 2 s old, and once more when the queue shuts down, after - * the events (D47: under PHP-FPM one post per request, after the response). Lines logged in the last shutdown pass, - * after `finish()`, are dropped unless a later `shutdown()` runs. A failure is silent: a log line would feed the buffer - * it reports on. + * the process decides. Lines are buffered (1 000 at most, the oldest dropped) and posted as a background request + * (`flush()` does not wait for it) at the first queue pass (`pump()`, `flush()`, `shutdown()`) once the oldest of them + * is 2 s old, and once more when the queue shuts down, after the events (D47: under PHP-FPM one post per request, after + * the response). Lines logged in the last shutdown pass, after `finish()`, are dropped unless a later `shutdown()` + * runs. A failure is silent: a log line would feed the buffer it reports on. * * @internal */ @@ -173,7 +173,7 @@ private function flush(): void try { $this->queue->channel()->post( - new HttpRequest(DiagnosticsEndpoint::url(), ['Content-Type' => 'application/json', 'Authorization' => 'Bearer ' . DiagnosticsEndpoint::token()], DiagnosticsPayload::encode($this->resource, $records, SdkVersion::get())), + new HttpRequest(DiagnosticsEndpoint::url(), ['Content-Type' => 'application/json', 'Authorization' => 'Bearer ' . DiagnosticsEndpoint::token()], DiagnosticsPayload::encode($this->resource, $records, SdkVersion::get()), background: true), static function (): void {}, // the answer is ignored (TS) ); } catch (\Throwable) { diff --git a/src/Internal/Transport/CurlHttpChannel.php b/src/Internal/Transport/CurlHttpChannel.php index dd877d2..81a3845 100644 --- a/src/Internal/Transport/CurlHttpChannel.php +++ b/src/Internal/Transport/CurlHttpChannel.php @@ -94,9 +94,20 @@ public function pump(): void } #[\Override] - public function pending(): int + public function pending(bool $includeBackground = true): int { - return count($this->waiting) + $this->multi->count(); + 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] @@ -142,7 +153,7 @@ private function launch(): void { while ($this->multi->count() < self::MAX_CONCURRENT && $this->waiting !== []) { $next = array_shift($this->waiting); - $transfer = new HttpTransfer($next['onDone']); + $transfer = new HttpTransfer($next['onDone'], $next['request']->background); try { $error = $this->multi->open($this->options($next['request'], $transfer), $transfer); diff --git a/src/Internal/Transport/CurlMulti.php b/src/Internal/Transport/CurlMulti.php index 8d83224..add3558 100644 --- a/src/Internal/Transport/CurlMulti.php +++ b/src/Internal/Transport/CurlMulti.php @@ -114,6 +114,12 @@ 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'); + } + private function multi(): \CurlMultiHandle { return $this->multi ??= curl_multi_init(); diff --git a/src/Internal/Transport/HttpChannel.php b/src/Internal/Transport/HttpChannel.php index bb9ce3a..5d39a59 100644 --- a/src/Internal/Transport/HttpChannel.php +++ b/src/Internal/Transport/HttpChannel.php @@ -23,8 +23,8 @@ public function post(HttpRequest $request, \Closure $onDone): void; /** Starts waiting requests, collects finished ones and runs their callbacks. Never blocks or throws. */ public function pump(): void; - /** Requests waiting to start plus requests in transfer. */ - public function pending(): int; + /** Requests waiting to start plus requests in transfer; without the background ones when `$includeBackground` is false. */ + public function pending(bool $includeBackground = true): int; /** Blocks until a transfer can progress or `$seconds` pass, at most 1 s. Does no work. */ public function waitForActivity(float $seconds): void; diff --git a/src/Internal/Transport/HttpRequest.php b/src/Internal/Transport/HttpRequest.php index 9b27d72..d60c757 100644 --- a/src/Internal/Transport/HttpRequest.php +++ b/src/Internal/Transport/HttpRequest.php @@ -11,10 +11,15 @@ */ final readonly class HttpRequest { - /** @param array $headers name => value, sent in this order */ + /** + * @param array $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()`) + */ public function __construct( public string $url, public array $headers, public string $body, + public bool $background = false, ) {} } diff --git a/src/Internal/Transport/HttpTransfer.php b/src/Internal/Transport/HttpTransfer.php index 8d257f3..5b3dd59 100644 --- a/src/Internal/Transport/HttpTransfer.php +++ b/src/Internal/Transport/HttpTransfer.php @@ -18,6 +18,12 @@ final class HttpTransfer /** At most `CurlHttpChannel::MAX_BODY_BYTES` of the body. */ public string $body = ''; - /** @param \Closure(HttpResponse): void $onDone */ - public function __construct(public readonly \Closure $onDone) {} + /** + * @param \Closure(HttpResponse): void $onDone + * @param bool $background `HttpRequest::$background` + */ + public function __construct( + public readonly \Closure $onDone, + public readonly bool $background, + ) {} } diff --git a/tests/Integration/Transport/CurlHttpChannelTest.php b/tests/Integration/Transport/CurlHttpChannelTest.php index 4ebd72a..97104b6 100644 --- a/tests/Integration/Transport/CurlHttpChannelTest.php +++ b/tests/Integration/Transport/CurlHttpChannelTest.php @@ -222,6 +222,23 @@ public function testTheResponseBodyIsCappedAt64KiB(): void $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)); + } + /** * 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. diff --git a/tests/Support/RecordingHttpChannel.php b/tests/Support/RecordingHttpChannel.php index 298d5f9..855a463 100644 --- a/tests/Support/RecordingHttpChannel.php +++ b/tests/Support/RecordingHttpChannel.php @@ -17,7 +17,7 @@ final class RecordingHttpChannel implements HttpChannel /** @var list the `$seconds` of every waitForActivity() call */ public array $waits = []; - /** @var list */ + /** @var list */ private array $held = []; private readonly HttpResponse $default; @@ -39,7 +39,7 @@ public function __construct( public function post(HttpRequest $request, \Closure $onDone): void { $this->requests[] = $request; - $this->held[] = ['index' => count($this->requests) - 1, 'onDone' => $onDone]; + $this->held[] = ['index' => count($this->requests) - 1, 'onDone' => $onDone, 'background' => $request->background]; } #[\Override] @@ -52,9 +52,16 @@ public function pump(): void } #[\Override] - public function pending(): int + public function pending(bool $includeBackground = true): int { - return count($this->held); + $pending = 0; + foreach ($this->held as $held) { + if ($includeBackground || !$held['background']) { + ++$pending; + } + } + + return $pending; } #[\Override] diff --git a/tests/Unit/Internal/Delivery/EventQueueTest.php b/tests/Unit/Internal/Delivery/EventQueueTest.php index b038178..b9b2700 100644 --- a/tests/Unit/Internal/Delivery/EventQueueTest.php +++ b/tests/Unit/Internal/Delivery/EventQueueTest.php @@ -760,6 +760,75 @@ public function testFlushReportsFalseWhileASideRequestIsStillPending(): void $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 diff --git a/tests/Unit/Internal/Diagnostics/DiagnosticsTest.php b/tests/Unit/Internal/Diagnostics/DiagnosticsTest.php index 73ca455..8fb969b 100644 --- a/tests/Unit/Internal/Diagnostics/DiagnosticsTest.php +++ b/tests/Unit/Internal/Diagnostics/DiagnosticsTest.php @@ -163,6 +163,43 @@ public function testTheTwoSecondWindowIsCheckedOnQueuePasses(): void $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 { @@ -195,7 +232,7 @@ public function post(HttpRequest $request, \Closure $onDone): void public function pump(): void {} #[\Override] - public function pending(): int + public function pending(bool $includeBackground = true): int { return 0; } @@ -246,9 +283,9 @@ public function pump(): void } #[\Override] - public function pending(): int + public function pending(bool $includeBackground = true): int { - return $this->inner->pending(); + return $this->inner->pending($includeBackground); } #[\Override] diff --git a/tests/Unit/Internal/Transport/CurlHttpChannelUnitTest.php b/tests/Unit/Internal/Transport/CurlHttpChannelUnitTest.php index 95c71c4..4dc67f2 100644 --- a/tests/Unit/Internal/Transport/CurlHttpChannelUnitTest.php +++ b/tests/Unit/Internal/Transport/CurlHttpChannelUnitTest.php @@ -214,5 +214,20 @@ public function testARequestKeepsItsHeadersInOrder(): void $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'); } + + /** 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)); + } + } From c8a7609b2ed15d32229efb6eba6d7a6be4720e0f Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Sun, 18 Oct 2026 02:10:39 +0000 Subject: [PATCH 23/35] fix: leave the side channel alone when a signal handler re-enters it 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). --- src/Internal/Support/OnRelease.php | 22 ++++ src/Internal/Transport/CurlHttpChannel.php | 22 +++- src/Internal/Transport/CurlMulti.php | 44 ++++++- .../Delivery/EventQueueDeliveryTest.php | 47 +++++++ .../Delivery/SignalInCallbackTest.php | 120 ++++++++++++++++++ .../Integration/Fixtures/exit-in-callback.php | 45 +++++++ .../Fixtures/signal-in-callback.php | 82 ++++++++++++ .../Transport/CurlHttpChannelTest.php | 52 +++++++- tests/Integration/Transport/CurlMultiTest.php | 90 +++++++++++++ 9 files changed, 515 insertions(+), 9 deletions(-) create mode 100644 src/Internal/Support/OnRelease.php create mode 100644 tests/Integration/Delivery/SignalInCallbackTest.php create mode 100644 tests/Integration/Fixtures/exit-in-callback.php create mode 100644 tests/Integration/Fixtures/signal-in-callback.php create mode 100644 tests/Integration/Transport/CurlMultiTest.php diff --git a/src/Internal/Support/OnRelease.php b/src/Internal/Support/OnRelease.php new file mode 100644 index 0000000..d194875 --- /dev/null +++ b/src/Internal/Support/OnRelease.php @@ -0,0 +1,22 @@ +release)(); + } +} diff --git a/src/Internal/Transport/CurlHttpChannel.php b/src/Internal/Transport/CurlHttpChannel.php index 81a3845..04e7670 100644 --- a/src/Internal/Transport/CurlHttpChannel.php +++ b/src/Internal/Transport/CurlHttpChannel.php @@ -13,6 +13,8 @@ * every throwable is swallowed (D32; tests see them through `$onSwallowed`): a failed request reaches only its * callback. `CurlMulti` runs the transfers. * + * A call from inside one of its own transfer callbacks (a signal handler) leaves the transfers alone (`CurlMulti`). + * * @internal */ final class CurlHttpChannel implements HttpChannel @@ -51,6 +53,8 @@ final class CurlHttpChannel implements HttpChannel * @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(): void)|null $onWrite tests only: runs at the start of every write callback, inside + * `curl_multi_exec()`, where a signal handler can run too * * @throws \InvalidArgumentException for a timeout curl cannot take (`CurlSupport::timeoutMilliseconds()`) */ @@ -60,6 +64,7 @@ public function __construct( ?\Closure $functionExists = null, ?\Closure $multiStatus = null, private readonly ?\Closure $onSwallowed = null, + private readonly ?\Closure $onWrite = null, ) { $this->connectTimeoutMs = CurlSupport::timeoutMilliseconds($connectTimeoutSeconds); $this->totalTimeoutMs = CurlSupport::timeoutMilliseconds($totalTimeoutSeconds); @@ -93,9 +98,13 @@ public function pump(): void }); } + /** 0 from inside one of its own transfer callbacks (a signal drain): nothing can progress there, so nothing can be waited for. */ #[\Override] public function pending(bool $includeBackground = true): int { + if ($this->multi->executing()) { + return 0; + } if ($includeBackground) { return count($this->waiting) + $this->multi->count(); } @@ -148,10 +157,13 @@ private function swallowed(\Throwable $error): void } } - /** Starts waiting requests while slots are free; the next collect() runs their first curl_multi_exec(). */ + /** + * Starts waiting requests while slots are free; the next collect() runs their first curl_multi_exec(). From inside + * a transfer callback none starts: they wait for the next pump. + */ private function launch(): void { - while ($this->multi->count() < self::MAX_CONCURRENT && $this->waiting !== []) { + while (!$this->multi->executing() && $this->multi->count() < self::MAX_CONCURRENT && $this->waiting !== []) { $next = array_shift($this->waiting); $transfer = new HttpTransfer($next['onDone'], $next['request']->background); @@ -203,8 +215,12 @@ private function options(HttpRequest $request, HttpTransfer $transfer): array foreach ($request->headers as $name => $value) { $headers[] = $name . ': ' . $value; } + $onWrite = $this->onWrite; $capture = [ - CURLOPT_WRITEFUNCTION => static function (\CurlHandle $handle, string $data) use ($transfer): int { + CURLOPT_WRITEFUNCTION => static function (\CurlHandle $handle, string $data) use ($transfer, $onWrite): int { + if ($onWrite instanceof \Closure) { + $onWrite(); + } // 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)); diff --git a/src/Internal/Transport/CurlMulti.php b/src/Internal/Transport/CurlMulti.php index add3558..c0ec005 100644 --- a/src/Internal/Transport/CurlMulti.php +++ b/src/Internal/Transport/CurlMulti.php @@ -4,6 +4,8 @@ namespace AgentCat\Internal\Transport; +use AgentCat\Internal\Support\OnRelease; + /** * The curl_multi engine of `CurlTransport` and `CurlHttpChannel`: one multi handle, made at the first use, and its * transfers, each with its caller's payload. It keeps the rules that stop a waiting caller from hanging: a transfer @@ -11,6 +13,12 @@ * leaves the multi handle again, a failed `curl_multi_exec()` fails every transfer, and every finished handle leaves * the multi handle. The callers choose what a result means (retries, callbacks) and suppress PHP warnings. * + * A signal handler runs between two opcodes, so also inside a PHP callback of `curl_multi_exec()` (the write and header + * functions of `CurlHttpChannel`; `CurlTransport` sets none). libcurl refuses every multi call from there + * (`CURLM_RECURSIVE_API_CALL`), and failing the transfers there would free a handle that libcurl still uses: PHP + * crashes when the callback returns. So while `curl_multi_exec()` runs (`executing()`), `run()` and `select()` return + * at once, and `CurlHttpChannel` starts no transfer: a SIGTERM drain in that callback leaves this handle alone. + * * @template TPayload * * @internal @@ -26,6 +34,9 @@ final class CurlMulti /** @var \Closure(string, int): int */ private readonly \Closure $multiStatus; + /** True while `curl_multi_exec()` runs. */ + private bool $executing = false; + /** * @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 @@ -37,7 +48,8 @@ public function __construct(?\Closure $multiStatus = null) /** * Starts a transfer with `$options`. Null when it runs; 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. + * A throwable of curl_setopt_array() (a NUL byte in a string option) reaches the caller. Not while `executing()`: + * libcurl refuses the add from inside a callback, so a caller keeps its request waiting then. * * @param array $options * @param TPayload $payload @@ -68,14 +80,26 @@ public function open(array $options, mixed $payload): ?string /** * 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. + * so that nothing waits for them forever. While `executing()` (a call from inside a callback of the running + * `curl_multi_exec()`), it does nothing. * * @param \Closure(TPayload, \CurlHandle, int): void $finished * @param \Closure(TPayload, string): void $failed */ public function run(\Closure $finished, \Closure $failed): void { - $code = ($this->multiStatus)('curl_multi_exec', curl_multi_exec($this->multi(), $running)); + if ($this->executing) { + return; + } + $this->executing = true; + // Cleared when released below, and also when exit() in a signal handler inside a callback unwinds this frame + // (no finally block runs then): the shutdown functions can use the handle again. + $executing = new OnRelease(function (): void { + $this->executing = false; + }); + $result = curl_multi_exec($this->multi(), $running); + unset($executing); + $code = ($this->multiStatus)('curl_multi_exec', $result); if ($code !== CURLM_OK) { $transfers = $this->transfers; $this->transfers = []; @@ -100,14 +124,26 @@ public function run(\Closure $finished, \Closure $failed): void } } - /** Blocks until a transfer can progress or `$seconds` pass (0 or more, see `CurlSupport::waitSeconds()`). Does no work. */ + /** + * Blocks until a transfer can progress or `$seconds` pass (0 or more, see `CurlSupport::waitSeconds()`). Does no + * work. While `executing()`, it returns at once: nothing progresses until the callback returns. + */ public function select(float $seconds): void { + if ($this->executing) { + return; + } if (curl_multi_select($this->multi(), $seconds) === -1) { usleep(1000); // the wait failed: a caller's loop must not spin } } + /** True while `curl_multi_exec()` runs: a call from inside one of its callbacks (a signal handler) must leave the handle alone. */ + public function executing(): bool + { + return $this->executing; + } + /** Transfers in the multi handle. */ public function count(): int { diff --git a/tests/Integration/Delivery/EventQueueDeliveryTest.php b/tests/Integration/Delivery/EventQueueDeliveryTest.php index c051350..bd964e5 100644 --- a/tests/Integration/Delivery/EventQueueDeliveryTest.php +++ b/tests/Integration/Delivery/EventQueueDeliveryTest.php @@ -16,6 +16,7 @@ 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; @@ -190,6 +191,52 @@ public function export(Event $event): void $this->assertSame(1, $channel->pending(), 'the slow export is abandoned'); } + /** + * I2: a SIGTERM drain that runs inside a write 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(onWrite: 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(1.0, $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/SignalInCallbackTest.php b/tests/Integration/Delivery/SignalInCallbackTest.php new file mode 100644 index 0000000..a8380ba --- /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, '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: PHP skips finally blocks then, but frees the frame, so the + * re-entrancy guard is cleared and the shutdown function can still send on the same channel. + */ + #[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, '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/Fixtures/exit-in-callback.php b/tests/Integration/Fixtures/exit-in-callback.php new file mode 100644 index 0000000..6ce8c6f --- /dev/null +++ b/tests/Integration/Fixtures/exit-in-callback.php @@ -0,0 +1,45 @@ + +// Prints "exit", then from the shutdown function "after: " and "pending: ". +$url = $argv[1] ?? ''; +$exited = false; +$channel = new CurlHttpChannel(onWrite: static function () use (&$exited): void { + if (!$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-in-callback.php b/tests/Integration/Fixtures/signal-in-callback.php new file mode 100644 index 0000000..0fd1e7f --- /dev/null +++ b/tests/Integration/Fixtures/signal-in-callback.php @@ -0,0 +1,82 @@ + +// 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 $frame) { + if ($frame['function'] === 'curl_multi_exec') { + ++$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/Transport/CurlHttpChannelTest.php b/tests/Integration/Transport/CurlHttpChannelTest.php index 97104b6..bfdfeb4 100644 --- a/tests/Integration/Transport/CurlHttpChannelTest.php +++ b/tests/Integration/Transport/CurlHttpChannelTest.php @@ -239,6 +239,50 @@ public function testABackgroundRequestInTransferIsLeftOutOfTheForegroundCount(): $this->assertTrue($this->drain($channel, 2.0)); } + /** + * I2: a signal handler (the SIGTERM drain) that runs inside a write callback pumps, waits on and posts to the channel + * it interrupted. libcurl refuses every multi call from inside a callback; the nested calls leave the transfers + * alone, the new request waits for the next pump, and both complete. + */ + #[MaximumDuration(3000)] + 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(onWrite: static function () use (&$channel, &$nested, $url, $record): void { + if ($nested !== [] || !$channel instanceof CurlHttpChannel) { + return; + } + $begin = hrtime(true); + $channel->post(new HttpRequest($url . '/b', [], ''), $record('b')); + $channel->pump(); + $channel->waitForActivity(1.0); + $nested = [ + 'seconds' => (hrtime(true) - $begin) / 1e9, + 'pending' => $channel->pending(), + 'inTransfer' => $channel->inTransfer(), + ]; + }); + $channel->post(new HttpRequest($url . '/a', [], ''), $record('a')); + + $this->assertTrue($this->drain($channel, 2.0)); + + ksort($responses); + $this->assertSame(['a' => [200, null], 'b' => [200, null]], $responses, 'no curl error, no lost transfer'); + $this->assertSame(['seconds', 'pending', 'inTransfer'], array_keys($nested), 'the seam ran inside a write callback'); + $this->assertLessThan(0.5, $nested['seconds'] ?? INF, 'the nested wait returned at once'); + $this->assertSame(0, $nested['pending'] ?? null, 'inside a callback nothing can be waited for'); + $this->assertSame(1, $nested['inTransfer'] ?? null, 'b waited: libcurl refuses an add from inside a callback'); + $this->assertCount(2, $this->collector()->requests($url)); + } + /** * 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. @@ -544,15 +588,19 @@ public function testAWaitWithANanOrNegativeBoundReturnsAtOnceDuringATransfer(): $this->assertMatchesRegularExpression('/\AcURL error \d+: /', (string) $errors[0]); } - /** @param (\Closure(string, int): int)|null $multiStatus */ + /** + * @param (\Closure(string, int): int)|null $multiStatus + * @param (\Closure(): void)|null $onWrite + */ private function channel( float $connectTimeoutSeconds = CurlTransport::CONNECT_TIMEOUT_SECONDS, float $totalTimeoutSeconds = CurlTransport::TOTAL_TIMEOUT_SECONDS, ?\Closure $multiStatus = null, + ?\Closure $onWrite = null, ): CurlHttpChannel { return new CurlHttpChannel($connectTimeoutSeconds, $totalTimeoutSeconds, multiStatus: $multiStatus, onSwallowed: function (\Throwable $error): void { $this->swallowed[] = $error; - }); + }, onWrite: $onWrite); } private function drain(HttpChannel $channel, float $seconds): bool diff --git a/tests/Integration/Transport/CurlMultiTest.php b/tests/Integration/Transport/CurlMultiTest.php new file mode 100644 index 0000000..414ac1e --- /dev/null +++ b/tests/Integration/Transport/CurlMultiTest.php @@ -0,0 +1,90 @@ +stop(); + self::$collector = null; + } + + /** + * I2: a signal handler runs between two opcodes, so also inside a PHP write callback of `curl_multi_exec()`. A + * drain there calls the engine again: libcurl refuses every multi call (CURLM_RECURSIVE_API_CALL), and failing the + * transfers would free a handle that libcurl still uses (a segfault). The nested calls must return at once and + * leave the transfers alone. The test keeps every handle alive, so that a regression fails an assertion instead of + * crashing PHPUnit. + */ + #[MaximumDuration(3000)] + public function testACallFromInsideATransferCallbackLeavesTheTransfersAlone(): void + { + $url = $this->collector()->scenario([], ['status' => 200, 'body' => str_repeat('x', 200_000)]); + /** @var CurlMulti $multi */ + $multi = new CurlMulti(); + $finished = []; + $failed = []; + $onFinished = static function (string $payload, \CurlHandle $handle, int $result) use (&$finished, $multi): void { + $finished[] = [$payload, $result, $multi->executing()]; + }; + $onFailed = static function (string $payload, string $error) use (&$failed): void { + $failed[] = [$payload, $error]; + }; + $handles = []; + $nested = []; + $write = static function (\CurlHandle $handle, string $data) use ($multi, $onFinished, $onFailed, &$handles, &$nested): int { + $handles[spl_object_id($handle)] = $handle; + if ($nested === []) { + $begin = hrtime(true); + $nested['executing'] = $multi->executing(); + $multi->run($onFinished, $onFailed); + $multi->select(1.0); + $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')); + $until = microtime(true) + 2.0; + while ($multi->count() > 0) { + $this->assertLessThan($until, microtime(true), 'the transfer did not finish'); + $multi->run($onFinished, $onFailed); + $multi->select(0.05); + } + + $this->assertSame([], $failed, 'no transfer failed'); + $this->assertSame([['a', CURLE_OK, false]], $finished, 'the transfer completed once, after curl_multi_exec() returned'); + $this->assertSame(['executing', 'seconds', 'count'], array_keys($nested), 'the write callback ran'); + $this->assertTrue($nested['executing'] ?? null); + $this->assertLessThan(0.5, $nested['seconds'] ?? INF, 'the nested select returned at once'); + $this->assertSame(1, $nested['count'] ?? null, 'the transfer stayed in the multi handle'); + $this->assertFalse($multi->executing()); + } + + private function collector(): MockCollector + { + $this->assertInstanceOf(MockCollector::class, self::$collector); + + return self::$collector; + } +} From acc6ac3008e632c01d7b73095528d126e2d6bb9e Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Sun, 18 Oct 2026 02:11:05 +0000 Subject: [PATCH 24/35] fix: mask the user-info of an invalid Sentry DSN in its log line "Invalid Sentry 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. --- src/Internal/Telemetry/Sentry/SentryDsn.php | 25 +++++++- .../Telemetry/Sentry/SentryDsnTest.php | 58 +++++++++++-------- .../Telemetry/TelemetryManagerTest.php | 2 +- 3 files changed, 57 insertions(+), 28 deletions(-) diff --git a/src/Internal/Telemetry/Sentry/SentryDsn.php b/src/Internal/Telemetry/Sentry/SentryDsn.php index 8cab8fc..ab82850 100644 --- a/src/Internal/Telemetry/Sentry/SentryDsn.php +++ b/src/Internal/Telemetry/Sentry/SentryDsn.php @@ -19,14 +19,35 @@ private function __construct( public string $publicKey, ) {} - /** @throws \InvalidArgumentException */ + /** @throws \InvalidArgumentException `Invalid Sentry DSN: `, 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: ' . $dsn); + 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/tests/Unit/Internal/Telemetry/Sentry/SentryDsnTest.php b/tests/Unit/Internal/Telemetry/Sentry/SentryDsnTest.php index d5e01cb..347ba30 100644 --- a/tests/Unit/Internal/Telemetry/Sentry/SentryDsnTest.php +++ b/tests/Unit/Internal/Telemetry/Sentry/SentryDsnTest.php @@ -59,37 +59,45 @@ 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 testAnInvalidDsnThrowsWithTheDsnInTheMessage(string $dsn): void + public function testAnInvalidDsnThrowsWithTheMaskedDsnInTheMessage(string $dsn, string $logged): void { - $this->assertSame('Invalid Sentry DSN: ' . $dsn, Thrown::by(\InvalidArgumentException::class, static fn(): \AgentCat\Internal\Telemetry\Sentry\SentryDsn => SentryDsn::parse($dsn))->getMessage()); - $this->assertSame('Invalid Sentry DSN: ' . $dsn, Thrown::by(\InvalidArgumentException::class, static fn(): \AgentCat\Internal\Telemetry\Sentry\SentryPayload => SentryPayload::of(new SentryExporter($dsn)))->getMessage()); + $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 */ + /** @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']; - yield 'secret' => ['https://abc:secret@o1.ingest.sentry.io/42']; - yield 'non-numeric project' => ['https://abc@o1.ingest.sentry.io/proj']; - yield 'scheme' => ['ftp://abc@h/1']; - yield 'trailing newline' => ["https://abc@h/42\n"]; - yield 'leading text' => [' https://abc@h/42']; - yield 'no key' => ['https://h/42']; - yield 'no project' => ['https://abc@h/']; - yield 'empty key' => ['https://@h/42']; - yield 'non-hex key' => ['https://xyz@h/42']; - yield 'port without digits' => ['https://abc@h:/42']; - yield 'letters in the port' => ['https://abc@h:80a/42']; - yield 'space in the host' => ['https://abc@h h/42']; - yield 'carriage return in the path' => ["https://abc@h/a\rb/42"]; - yield 'line separator in the path' => ["https://abc@h/a\u{2028}b/42"]; - yield 'paragraph separator in the path' => ["https://abc@h/a\u{2029}b/42"]; - yield 'trailing project text' => ['https://abc@h/42x']; - yield 'non-ASCII letter in the host' => ['https://abc@é.example.com/42']; - yield 'non-ASCII digits in the project' => ['https://abc@o1.sentry.io/٤٢']; - yield 'non-ASCII digits in the port' => ['https://abc@h:٨٠/42']; - yield 'empty' => ['']; + 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/TelemetryManagerTest.php b/tests/Unit/Internal/Telemetry/TelemetryManagerTest.php index a6f4d4e..fe72d9a 100644 --- a/tests/Unit/Internal/Telemetry/TelemetryManagerTest.php +++ b/tests/Unit/Internal/Telemetry/TelemetryManagerTest.php @@ -137,7 +137,7 @@ public function testAnInvalidDsnDoesNotStopTheNextExporter(): void $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://ABC@o1.ingest.sentry.io/42', + '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, From 39a866f1ec852245d84e4b90515f5f10a593cbb5 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Sun, 18 Oct 2026 02:11:05 +0000 Subject: [PATCH 25/35] fix: read disableDiagnostics from config arrays like DISABLE_DIAGNOSTICS 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. --- .../Diagnostics/DiagnosticsSwitch.php | 39 +++++++++++- src/Options.php | 22 ++++++- tests/Unit/OptionsTest.php | 63 ++++++++++++++++++- 3 files changed, 118 insertions(+), 6 deletions(-) diff --git a/src/Internal/Diagnostics/DiagnosticsSwitch.php b/src/Internal/Diagnostics/DiagnosticsSwitch.php index 565dde2..21518ea 100644 --- a/src/Internal/Diagnostics/DiagnosticsSwitch.php +++ b/src/Internal/Diagnostics/DiagnosticsSwitch.php @@ -20,18 +20,48 @@ final class DiagnosticsSwitch public const string FORCE_ENABLED = 'force-enabled'; + /** The words of `DISABLE_DIAGNOSTICS` that keep diagnostics on (trimmed, any case). */ + private const array OFF = ['false', '0', 'no', 'off']; + + /** The words that turn diagnostics off in the `disableDiagnostics` option (`option()`). */ + private const array ON = ['true', '1', 'yes', 'on']; + /** * TS `envDiagnosticsFlag`: blank is unset; `false`, `0`, `no` or `off` (trimmed, any case) is a deliberate opt-in * that beats the test-environment default; any other value disables. */ public static function flag(?string $raw): string { - $value = strtolower(Js::trim($raw ?? '')); + $value = self::word($raw ?? ''); if ($value === '') { return self::UNSET; } - return in_array($value, ['false', '0', 'no', 'off'], true) ? self::FORCE_ENABLED : self::DISABLED; + return in_array($value, self::OFF, true) ? self::FORCE_ENABLED : self::DISABLED; + } + + /** + * The `disableDiagnostics` option of a config array (`Options::fromArray()`), in the vocabulary of + * `DISABLE_DIAGNOSTICS`: a bool; int 1 or 0; `true`, `1`, `yes` or `on` (trimmed, any case) for true; `false`, `0`, + * `no`, `off` or blank for false. Null for any other value: the caller decides (the option is an opt-out). + */ + public static function option(mixed $value): ?bool + { + if (is_bool($value)) { + return $value; + } + if ($value === 1 || $value === 0) { + return $value === 1; + } + if (!is_string($value)) { + return null; + } + $word = self::word($value); + if ($word === '' || in_array($word, self::OFF, true)) { + return false; + } + + return in_array($word, self::ON, true) ? true : null; } public static function enabled(bool $disabledOption, ?string $raw, bool $testEnvironment): bool @@ -40,4 +70,9 @@ public static function enabled(bool $disabledOption, ?string $raw, bool $testEnv return !$disabledOption && $flag !== self::DISABLED && ($flag === self::FORCE_ENABLED || !$testEnvironment); } + + private static function word(string $raw): string + { + return strtolower(Js::trim($raw)); + } } diff --git a/src/Options.php b/src/Options.php index 8be2ed3..a5ed6a9 100644 --- a/src/Options.php +++ b/src/Options.php @@ -8,6 +8,7 @@ use AgentCat\Exporters\OtlpExporter; use AgentCat\Exporters\PostHogExporter; use AgentCat\Exporters\SentryExporter; +use AgentCat\Internal\Diagnostics\DiagnosticsSwitch; use AgentCat\Internal\Logging\Log; use AgentCat\Internal\Telemetry\ExporterConfigs; @@ -74,6 +75,10 @@ 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 `DISABLE_DIAGNOSTICS` + * (`true`, `1`, `yes`, `on` turn diagnostics off; `false`, `0`, `no`, `off` and blank keep them on; trimmed, any + * case). Any other value, null included, logs the warning and turns diagnostics off: an opt-out fails closed. + * * @param array $config */ public static function fromArray(array $config): self @@ -98,7 +103,7 @@ public static function fromArray(array $config): self eventTags: self::closure($config, 'eventTags'), eventProperties: self::closure($config, 'eventProperties'), exporters: self::exporters($config), - disableDiagnostics: self::bool($config, 'disableDiagnostics', false), + disableDiagnostics: self::disableDiagnostics($config), disableSignalHandlers: self::bool($config, 'disableSignalHandlers', false), ); } @@ -117,6 +122,21 @@ private static function bool(array $config, string $key, bool $default): bool return $default; } + /** @param array $config */ + private static function disableDiagnostics(array $config): bool + { + if (!array_key_exists('disableDiagnostics', $config)) { + return false; + } + $disabled = DiagnosticsSwitch::option($config['disableDiagnostics']); + if ($disabled !== null) { + return $disabled; + } + self::reject('disableDiagnostics', 'bool', $config['disableDiagnostics']); + + return true; // a privacy opt-out fails closed + } + /** * @param array $config * diff --git a/tests/Unit/OptionsTest.php b/tests/Unit/OptionsTest.php index 0e9a4aa..73a0404 100644 --- a/tests/Unit/OptionsTest.php +++ b/tests/Unit/OptionsTest.php @@ -14,6 +14,7 @@ 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; @@ -261,18 +262,74 @@ public function testFromArraySkipsAnExporterWithMissingOrMistypedValues(): void public function testFromArrayRejectsWrongTelemetryOptionTypes(): void { - $options = Options::fromArray(['exporters' => 'otlp', 'disableDiagnostics' => 'yes', 'disableSignalHandlers' => 1]); + $options = Options::fromArray(['exporters' => 'otlp', 'disableSignalHandlers' => 1]); $this->assertNull($options->exporters); - $this->assertFalse($options->disableDiagnostics); $this->assertFalse($options->disableSignalHandlers); $this->assertSame([ 'WARN: AgentCat option "exporters" must be array or null, got string; the default is used', - 'WARN: AgentCat option "disableDiagnostics" must be bool, 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 `DISABLE_DIAGNOSTICS` + * words, trimmed, in any case. + * + * @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]; + } + + #[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 "null (env() of a missing variable)" => [null, 'null']; + yield 'an array' => [[], 'array']; + } + + /** A4: a privacy opt-out fails closed: a value it cannot read logs the usual 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 default is used'], + $this->logger->messages(), + ); + } + public function testFromArrayKeepsAnEmptyExporterListAndAnExplicitNull(): void { $this->assertSame([], Options::fromArray(['exporters' => []])->exporters); From 60a6c9f7114dacaba6c5789dc3d2ed983628575d Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Sun, 18 Oct 2026 02:11:36 +0000 Subject: [PATCH 26/35] fix: log the full export queue once per pass, with the number dropped 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 request(s)". --- src/Internal/Transport/CurlHttpChannel.php | 28 +++++++---- .../Transport/CurlHttpChannelUnitTest.php | 47 ++++++++++++++----- 2 files changed, 53 insertions(+), 22 deletions(-) diff --git a/src/Internal/Transport/CurlHttpChannel.php b/src/Internal/Transport/CurlHttpChannel.php index 04e7670..f76c06b 100644 --- a/src/Internal/Transport/CurlHttpChannel.php +++ b/src/Internal/Transport/CurlHttpChannel.php @@ -13,7 +13,8 @@ * every throwable is swallowed (D32; tests see them through `$onSwallowed`): a failed request reaches only its * callback. `CurlMulti` runs the transfers. * - * A call from inside one of its own transfer callbacks (a signal handler) leaves the transfers alone (`CurlMulti`). + * At most 1 000 requests wait; the oldest are dropped, with one line per pass. A call from inside one of its own + * transfer callbacks (a signal handler) leaves the transfers alone (`CurlMulti`). * * @internal */ @@ -30,7 +31,7 @@ final class CurlHttpChannel implements HttpChannel private const string NO_CURL = 'WARN: AgentCat cannot send telemetry exports or diagnostics: ext-curl functions are missing or disabled (%s)'; - private const string FULL = 'WARN: AgentCat telemetry export queue full, dropping the oldest request'; + private const string FULL = 'WARN: AgentCat telemetry export queue full, dropped the oldest %d request(s)'; /** The reason phrase of a status line; the last status line of a response wins. */ private const string STATUS_LINE = '~\AHTTP/\S+\s+\d{3}(?:[ \t]+([^\r\n]*))?~'; @@ -48,6 +49,9 @@ final class CurlHttpChannel implements HttpChannel /** @var list */ private array $waiting = []; + /** Requests dropped from a full queue since the last pass; `pump()` logs them in one line. */ + private int $dropped = 0; + /** * @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 @@ -80,22 +84,26 @@ public function post(HttpRequest $request, \Closure $onDone): void } if (count($this->waiting) >= self::MAX_WAITING) { array_shift($this->waiting); - Log::write(self::FULL); + ++$this->dropped; } $this->waiting[] = ['request' => $request, 'onDone' => $onDone]; } + /** Also logs the requests dropped since the last pass, in one line. */ #[\Override] public function pump(): void { - if ($this->waiting === [] && $this->multi->count() === 0) { - return; // nothing posted (or nothing can be): no curl call + if ($this->waiting !== [] || $this->multi->count() > 0) { // else nothing posted (or nothing can be): no curl call + $this->guarded(function (): void { + $this->launch(); + $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; } - $this->guarded(function (): void { - $this->launch(); - $this->collect(); - $this->launch(); // requests that callbacks posted (Sentry's sequence) start in this pass - }); } /** 0 from inside one of its own transfer callbacks (a signal drain): nothing can progress there, so nothing can be waited for. */ diff --git a/tests/Unit/Internal/Transport/CurlHttpChannelUnitTest.php b/tests/Unit/Internal/Transport/CurlHttpChannelUnitTest.php index 4dc67f2..d2d3438 100644 --- a/tests/Unit/Internal/Transport/CurlHttpChannelUnitTest.php +++ b/tests/Unit/Internal/Transport/CurlHttpChannelUnitTest.php @@ -16,7 +16,8 @@ /** No transfer starts here: nothing is pumped, curl is "missing", or every add is refused. tests/Integration/Transport covers the transfers. */ final class CurlHttpChannelUnitTest extends TestCase { - private const string FULL = 'WARN: AgentCat telemetry export queue full, dropping the oldest request'; + /** A5: at most one line per pass, with the count of that pass. */ + private const string FULL = 'WARN: AgentCat telemetry export queue full, dropped the oldest %d request(s)'; private const string NO_CURL = 'WARN: AgentCat cannot send telemetry exports or diagnostics: ext-curl functions are missing or disabled ('; @@ -35,30 +36,38 @@ protected function tearDown(): void Log::reset(); } - public function testAFullQueueDropsTheOldestRequestWithOneLineEach(): void + public function testAFullQueueDropsTheOldestRequestsWithOneLinePerPass(): void { // Every add is refused: one pump then fails each waiting request through its callback, and none is sent. $swallowed = []; - $channel = 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; - }, - ); + $channel = $this->refusingChannel($swallowed); $ran = []; - - for ($i = 0; $i < CurlHttpChannel::MAX_WAITING + 2; ++$i) { + $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([self::FULL, self::FULL], $this->logger->messages()); + $this->assertSame([], $this->logger->messages(), 'the line waits for the pass'); $channel->pump(); - $this->assertSame(range(2, 1001), $ran, 'r0 and r1, the oldest, were dropped'); + $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); } @@ -230,4 +239,18 @@ public function testPendingCanLeaveOutTheBackgroundRequests(): void $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; + }, + ); + } } From 9e29b3cb82f95bdfbc9ea82da8cbae1b537a0c14 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Sun, 18 Oct 2026 02:12:02 +0000 Subject: [PATCH 27/35] fix: cap the waiting side requests and the diagnostics buffer by bytes 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. --- src/Internal/Diagnostics/Diagnostics.php | 25 +++++-- src/Internal/Transport/CurlHttpChannel.php | 19 ++++-- .../Internal/Diagnostics/DiagnosticsTest.php | 65 +++++++++++++++++++ .../Transport/CurlHttpChannelUnitTest.php | 52 +++++++++++++++ 4 files changed, 150 insertions(+), 11 deletions(-) diff --git a/src/Internal/Diagnostics/Diagnostics.php b/src/Internal/Diagnostics/Diagnostics.php index a08d6c1..245ab65 100644 --- a/src/Internal/Diagnostics/Diagnostics.php +++ b/src/Internal/Diagnostics/Diagnostics.php @@ -14,11 +14,11 @@ /** * TypeScript diagnostics: every SDK log line (D07), as OTLP/JSON logs to AgentCat's collector. The first `init()` of - * the process decides. Lines are buffered (1 000 at most, the oldest dropped) and posted as a background request - * (`flush()` does not wait for it) at the first queue pass (`pump()`, `flush()`, `shutdown()`) once the oldest of them - * is 2 s old, and once more when the queue shuts down, after the events (D47: under PHP-FPM one post per request, after - * the response). Lines logged in the last shutdown pass, after `finish()`, are dropped unless a later `shutdown()` - * runs. A failure is silent: a log line would feed the buffer it reports on. + * the process decides. Lines are buffered (1 000 at most, with at most 1 MiB of bodies; the oldest are dropped) and + * posted as a background request (`flush()` does not wait for it) at the first queue pass (`pump()`, `flush()`, + * `shutdown()`) once the oldest of them is 2 s old, and once more when the queue shuts down, after the events (D47: + * under PHP-FPM one post per request, after the response). Lines logged in the last shutdown pass, after `finish()`, + * are dropped unless a later `shutdown()` runs. A failure is silent: a log line would feed the buffer it reports on. * * @internal */ @@ -26,6 +26,9 @@ final class Diagnostics implements QueueCompanion { public const int MAX_BUFFER = 1000; + /** The bodies of the buffered records, at most (1 MiB). */ + public const int MAX_BUFFER_BYTES = 1_048_576; + public const float BATCH_FLUSH_SECONDS = 2.0; private const string IGNORED = 'WARN: AgentCat diagnostics were set up by an earlier track(); disableDiagnostics: true in this call is ignored'; @@ -39,6 +42,9 @@ final class Diagnostics implements QueueCompanion /** @var list}> */ 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; @@ -141,9 +147,13 @@ private function capture(string $line): void $now = ($this->clock)(); [$number, $text] = Severity::of($line); $millis = ($this->wallMillis)(); - if (count($this->buffer) >= self::MAX_BUFFER) { - array_shift($this->buffer); + $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, @@ -169,6 +179,7 @@ private function flush(): void } $records = $this->buffer; $this->buffer = []; + $this->bufferBytes = 0; $this->firstAt = null; try { diff --git a/src/Internal/Transport/CurlHttpChannel.php b/src/Internal/Transport/CurlHttpChannel.php index f76c06b..03a3180 100644 --- a/src/Internal/Transport/CurlHttpChannel.php +++ b/src/Internal/Transport/CurlHttpChannel.php @@ -13,8 +13,8 @@ * every throwable is swallowed (D32; tests see them through `$onSwallowed`): a failed request reaches only its * callback. `CurlMulti` runs the transfers. * - * At most 1 000 requests wait; the oldest are dropped, with one line per pass. A call from inside one of its own - * transfer callbacks (a signal handler) leaves the transfers alone (`CurlMulti`). + * At most 1 000 requests with at most 16 MiB of bodies wait; the oldest are dropped, with one line per pass. A call + * from inside one of its own transfer callbacks (a signal handler) leaves the transfers alone (`CurlMulti`). * * @internal */ @@ -24,6 +24,9 @@ final class CurlHttpChannel implements HttpChannel public const int MAX_WAITING = 1000; + /** The bodies of the waiting requests, at most (16 MiB). */ + public const int MAX_WAITING_BYTES = 16_777_216; + public const int MAX_BODY_BYTES = 65_536; /** Upper bound of one wait, so a waiting caller re-checks its state at least once a second. */ @@ -49,6 +52,9 @@ final class CurlHttpChannel implements HttpChannel /** @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; @@ -82,11 +88,15 @@ public function post(HttpRequest $request, \Closure $onDone): void if (!$this->curl->usable()) { return; // dropped: nothing can send it } - if (count($this->waiting) >= self::MAX_WAITING) { - array_shift($this->waiting); + $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. */ @@ -173,6 +183,7 @@ private function launch(): void { while (!$this->multi->executing() && $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 { diff --git a/tests/Unit/Internal/Diagnostics/DiagnosticsTest.php b/tests/Unit/Internal/Diagnostics/DiagnosticsTest.php index 8fb969b..af3d662 100644 --- a/tests/Unit/Internal/Diagnostics/DiagnosticsTest.php +++ b/tests/Unit/Internal/Diagnostics/DiagnosticsTest.php @@ -10,6 +10,7 @@ use AgentCat\Internal\Diagnostics\DiagnosticsAttributes; use AgentCat\Internal\Json\Json; use AgentCat\Internal\Logging\Log; +use AgentCat\Internal\Logging\LogLine; use AgentCat\Internal\SdkVersion; use AgentCat\Internal\Transport\CurlHttpChannel; use AgentCat\Internal\Transport\HttpChannel; @@ -139,6 +140,70 @@ public function testTheBufferKeepsTheNewest1000Records(): void $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 { diff --git a/tests/Unit/Internal/Transport/CurlHttpChannelUnitTest.php b/tests/Unit/Internal/Transport/CurlHttpChannelUnitTest.php index d2d3438..be09a57 100644 --- a/tests/Unit/Internal/Transport/CurlHttpChannelUnitTest.php +++ b/tests/Unit/Internal/Transport/CurlHttpChannelUnitTest.php @@ -71,6 +71,58 @@ public function testAFullQueueDropsTheOldestRequestsWithOneLinePerPass(): void $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 { From 922ec2c23de04e11f0c09832c740763a1fb3495c Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Sun, 18 Oct 2026 02:12:02 +0000 Subject: [PATCH 28/35] docs: say exactly what diagnostics send in the disableDiagnostics PHPDoc 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. --- src/Options.php | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/Options.php b/src/Options.php index a5ed6a9..a62c46d 100644 --- a/src/Options.php +++ b/src/Options.php @@ -41,10 +41,13 @@ * @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: AgentCat's own log lines (the lines of agentcat.log), sent to - * AgentCat to find broken installs. The lines can contain session IDs, actor IDs, tool names and error messages. - * The first track() in the process decides. DISABLE_DIAGNOSTICS also turns them off, or on with false; they are - * off in test environments. + * @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. + * DISABLE_DIAGNOSTICS also turns them off, or on with false; they are off in test environments. * @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 From 1a677c6a5cf9d28ea0c039d12843cf21894ef79c Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Sun, 18 Oct 2026 02:12:02 +0000 Subject: [PATCH 29/35] ci: stop the mutation job after 45 minutes 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. --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) 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: From 9100c294b91e0f3f0812851d8bf99bc7f4819e03 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Sun, 18 Oct 2026 04:18:27 +0000 Subject: [PATCH 30/35] fix: read the diagnostics variables with the AGENTCAT_ prefix 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. --- src/Internal/Diagnostics/Diagnostics.php | 2 +- .../Diagnostics/DiagnosticsEndpoint.php | 10 ++- .../Diagnostics/DiagnosticsSwitch.php | 24 ++++-- src/Options.php | 11 ++- tests/E2E/Fixtures/http-server.php | 8 +- tests/E2E/HttpEndToEndTest.php | 2 +- tests/E2E/StdioEndToEndTest.php | 11 +-- tests/Integration/ClientDiagnosticsTest.php | 21 +++--- .../Delivery/SignalInCallbackTest.php | 4 +- .../Diagnostics/HardenedHostTest.php | 2 +- .../Diagnostics/DiagnosticsEndpointTest.php | 32 +++++--- .../Diagnostics/DiagnosticsSwitchTest.php | 31 +++++--- .../Internal/Diagnostics/DiagnosticsTest.php | 73 +++++++++++++++---- tests/bootstrap.php | 11 ++- 14 files changed, 167 insertions(+), 75 deletions(-) diff --git a/src/Internal/Diagnostics/Diagnostics.php b/src/Internal/Diagnostics/Diagnostics.php index 245ab65..ef360e9 100644 --- a/src/Internal/Diagnostics/Diagnostics.php +++ b/src/Internal/Diagnostics/Diagnostics.php @@ -85,7 +85,7 @@ public static function init(string $projectId, bool $disabled, EventQueue $queue } self::$initialized = true; $testEnvironment = $inTestEnvironment instanceof \Closure ? $inTestEnvironment() : TestMode::inTestEnvironment(); - if (!DiagnosticsSwitch::enabled($disabled, Env::get('DISABLE_DIAGNOSTICS'), $testEnvironment)) { + if (!DiagnosticsSwitch::enabled($disabled, Env::get('AGENTCAT_DISABLE_DIAGNOSTICS'), Env::get('DISABLE_DIAGNOSTICS'), $testEnvironment)) { return; } $diagnostics = new self( diff --git a/src/Internal/Diagnostics/DiagnosticsEndpoint.php b/src/Internal/Diagnostics/DiagnosticsEndpoint.php index f0e8490..b855f73 100644 --- a/src/Internal/Diagnostics/DiagnosticsEndpoint.php +++ b/src/Internal/Diagnostics/DiagnosticsEndpoint.php @@ -9,22 +9,24 @@ /** * Where diagnostics go, read at each flush (TS `resolveEndpoint`, `resolveToken`). An empty variable counts as unset. + * The names carry the `AGENTCAT_` prefix (D50, a divergence from TypeScript): an application's own `DIAGNOSTICS_ENDPOINT` + * or `DIAGNOSTICS_TOKEN` is never read, so it never sends AgentCat's batches elsewhere or its secret to AgentCat. * * @internal */ final class DiagnosticsEndpoint { - /** `DIAGNOSTICS_ENDPOINT` or the default, trailing slashes removed, ending in `/v1/logs`. */ + /** `AGENTCAT_DIAGNOSTICS_ENDPOINT` or the default, trailing slashes removed, ending in `/v1/logs`. */ public static function url(): string { - $base = rtrim(Env::get('DIAGNOSTICS_ENDPOINT') ?? Constants::DEFAULT_DIAGNOSTICS_ENDPOINT, '/'); + $base = rtrim(Env::get('AGENTCAT_DIAGNOSTICS_ENDPOINT') ?? Constants::DEFAULT_DIAGNOSTICS_ENDPOINT, '/'); return str_ends_with($base, '/v1/logs') ? $base : $base . '/v1/logs'; } - /** `DIAGNOSTICS_TOKEN` or the public default token. */ + /** `AGENTCAT_DIAGNOSTICS_TOKEN` or the public default token. */ public static function token(): string { - return Env::get('DIAGNOSTICS_TOKEN') ?? Constants::DEFAULT_DIAGNOSTICS_TOKEN; + return Env::get('AGENTCAT_DIAGNOSTICS_TOKEN') ?? Constants::DEFAULT_DIAGNOSTICS_TOKEN; } } diff --git a/src/Internal/Diagnostics/DiagnosticsSwitch.php b/src/Internal/Diagnostics/DiagnosticsSwitch.php index 21518ea..5062b1a 100644 --- a/src/Internal/Diagnostics/DiagnosticsSwitch.php +++ b/src/Internal/Diagnostics/DiagnosticsSwitch.php @@ -7,8 +7,9 @@ use AgentCat\Internal\Support\Js; /** - * TypeScript `initDiagnostics`'s decision: the `disableDiagnostics` option, then `DISABLE_DIAGNOSTICS`, then the test - * environment. + * TypeScript `initDiagnostics`'s decision: the `disableDiagnostics` option, then `AGENTCAT_DISABLE_DIAGNOSTICS`, then + * the test environment. While `AGENTCAT_DISABLE_DIAGNOSTICS` is blank, the unprefixed `DISABLE_DIAGNOSTICS` (the + * TypeScript name) can turn diagnostics off, never on (D50). * * @internal */ @@ -20,7 +21,7 @@ final class DiagnosticsSwitch public const string FORCE_ENABLED = 'force-enabled'; - /** The words of `DISABLE_DIAGNOSTICS` that keep diagnostics on (trimmed, any case). */ + /** The words of `AGENTCAT_DISABLE_DIAGNOSTICS` that keep diagnostics on (trimmed, any case). */ private const array OFF = ['false', '0', 'no', 'off']; /** The words that turn diagnostics off in the `disableDiagnostics` option (`option()`). */ @@ -42,8 +43,9 @@ public static function flag(?string $raw): string /** * The `disableDiagnostics` option of a config array (`Options::fromArray()`), in the vocabulary of - * `DISABLE_DIAGNOSTICS`: a bool; int 1 or 0; `true`, `1`, `yes` or `on` (trimmed, any case) for true; `false`, `0`, - * `no`, `off` or blank for false. Null for any other value: the caller decides (the option is an opt-out). + * `AGENTCAT_DISABLE_DIAGNOSTICS`: a bool; int 1 or 0; `true`, `1`, `yes` or `on` (trimmed, any case) for true; + * `false`, `0`, `no`, `off` or blank for false. Null for any other value: the caller decides (the option is an + * opt-out). */ public static function option(mixed $value): ?bool { @@ -64,9 +66,17 @@ public static function option(mixed $value): ?bool return in_array($word, self::ON, true) ? true : null; } - public static function enabled(bool $disabledOption, ?string $raw, bool $testEnvironment): bool + /** + * @param ?string $variable `AGENTCAT_DISABLE_DIAGNOSTICS` + * @param ?string $fallback `DISABLE_DIAGNOSTICS`, read only while `$variable` is blank; it can only turn + * diagnostics off + */ + public static function enabled(bool $disabledOption, ?string $variable, ?string $fallback, bool $testEnvironment): bool { - $flag = self::flag($raw); + $flag = self::flag($variable); + if ($flag === self::UNSET && self::flag($fallback) === self::DISABLED) { + $flag = self::DISABLED; // a false value there never turns diagnostics on + } return !$disabledOption && $flag !== self::DISABLED && ($flag === self::FORCE_ENABLED || !$testEnvironment); } diff --git a/src/Options.php b/src/Options.php index a62c46d..d1bd9d5 100644 --- a/src/Options.php +++ b/src/Options.php @@ -47,7 +47,9 @@ * 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. - * DISABLE_DIAGNOSTICS also turns them off, or on with false; they are off in test environments. + * AGENTCAT_DISABLE_DIAGNOSTICS also turns them off, or on with false; DISABLE_DIAGNOSTICS can only turn + * them off. They are off in test environments. AGENTCAT_DIAGNOSTICS_ENDPOINT and AGENTCAT_DIAGNOSTICS_TOKEN + * change where they go. * @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 @@ -78,9 +80,10 @@ 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 `DISABLE_DIAGNOSTICS` - * (`true`, `1`, `yes`, `on` turn diagnostics off; `false`, `0`, `no`, `off` and blank keep them on; trimmed, any - * case). Any other value, null included, logs the warning and turns diagnostics off: an opt-out fails closed. + * `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). Any other value, null included, logs the warning and turns diagnostics + * off: an opt-out fails closed. * * @param array $config */ diff --git a/tests/E2E/Fixtures/http-server.php b/tests/E2E/Fixtures/http-server.php index 6fe1b41..cbc10ec 100644 --- a/tests/E2E/Fixtures/http-server.php +++ b/tests/E2E/Fixtures/http-server.php @@ -30,12 +30,12 @@ throw new \RuntimeException('e2e boom'); }, 'boom', description: 'Always fails', inputSchema: ['type' => 'object', 'properties' => []]); -// Diagnostics stay off (the inherited 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 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('DIAGNOSTICS_ENDPOINT=' . $diagnostics); - putenv('DISABLE_DIAGNOSTICS=false'); + putenv('AGENTCAT_DIAGNOSTICS_ENDPOINT=' . $diagnostics); + putenv('AGENTCAT_DISABLE_DIAGNOSTICS=false'); } AgentCat::track($builder, 'proj_' . str_repeat('e', 27), new Options( diff --git a/tests/E2E/HttpEndToEndTest.php b/tests/E2E/HttpEndToEndTest.php index cfecbb4..0324765 100644 --- a/tests/E2E/HttpEndToEndTest.php +++ b/tests/E2E/HttpEndToEndTest.php @@ -74,7 +74,7 @@ public static function setUpBeforeClass(): void self::$server = BuiltInServer::start(__DIR__ . '/Fixtures/http-server.php', [ 'AGENTCAT_ENABLED' => 'true', 'AGENTCAT_API_URL' => self::$fallbackUrl, - 'DIAGNOSTICS_ENDPOINT' => MockCollector::closedPortUrl(), // a request without X-E2E-Diagnostics stays local + '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', diff --git a/tests/E2E/StdioEndToEndTest.php b/tests/E2E/StdioEndToEndTest.php index 6133c9a..fed39a0 100644 --- a/tests/E2E/StdioEndToEndTest.php +++ b/tests/E2E/StdioEndToEndTest.php @@ -252,7 +252,7 @@ public function testStdinEofSendsTheDiagnosticsOnce(): void { $diagnostics = $this->collector()->scenario(); $startedAt = microtime(true); - $client = $this->start(env: ['DISABLE_DIAGNOSTICS' => 'false', 'DIAGNOSTICS_ENDPOINT' => $diagnostics]); + $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']]); @@ -295,9 +295,10 @@ public function testAModernEraFirstRequestIsTrackedWithoutAHandshake(): void } /** - * Starts this test's server with its own collector scenario and HOME. `AGENTCAT_API_URL` and `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 `DISABLE_DIAGNOSTICS=true`, can never reach AgentCat's collector. + * 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 @@ -310,7 +311,7 @@ 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(), - 'DIAGNOSTICS_ENDPOINT' => MockCollector::closedPortUrl(), + 'AGENTCAT_DIAGNOSTICS_ENDPOINT' => MockCollector::closedPortUrl(), 'AGENTCAT_E2E_COLLECTOR' => $this->scenario, 'HOME' => $this->home, ...$env, diff --git a/tests/Integration/ClientDiagnosticsTest.php b/tests/Integration/ClientDiagnosticsTest.php index c589d25..9b93cf7 100644 --- a/tests/Integration/ClientDiagnosticsTest.php +++ b/tests/Integration/ClientDiagnosticsTest.php @@ -37,7 +37,7 @@ final class ClientDiagnosticsTest extends TestCase private Environment $environment; - /** `DIAGNOSTICS_ENDPOINT`: a closed local port (ruling P8). */ + /** `AGENTCAT_DIAGNOSTICS_ENDPOINT`: a closed local port (ruling P8). */ private string $endpoint = ''; private MemoryLogger $logger; @@ -45,9 +45,10 @@ final class ClientDiagnosticsTest extends TestCase #[\Override] protected function setUp(): void { - $this->environment = Environment::clear('DISABLE_DIAGNOSTICS', 'DIAGNOSTICS_ENDPOINT', 'DIAGNOSTICS_TOKEN', 'AGENTCAT_PROJECT_ID', 'AGENTCAT_API_URL', 'MCPCAT_API_URL', 'AGENTCAT_ENABLED', 'APP_ENV'); + $this->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('DIAGNOSTICS_ENDPOINT=' . $this->endpoint); // fail closed: never otel.agentcat.com + 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(); @@ -70,7 +71,7 @@ protected function tearDown(): void #[Group('ac-01-60')] public function testTrackSetsDiagnosticsUpBeforeTheCompatibilityCheck(): void { - putenv('DISABLE_DIAGNOSTICS=false'); // PHPUnit is a test environment: force it on, as a customer CI could + 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)); @@ -99,7 +100,7 @@ public function testATestEnvironmentKeepsDiagnosticsOffByDefault(): void #[Group('ac-01-11')] public function testAFirstTrackWithDiagnosticsDisabledKeepsThemOff(): void { - putenv('DISABLE_DIAGNOSTICS=false'); + putenv('AGENTCAT_DISABLE_DIAGNOSTICS=false'); $client = $this->client(new RecordingHttpChannel()); $client->track(TrackingFixture::builder(), self::PROJECT, new Options(disableDiagnostics: true)); @@ -113,7 +114,7 @@ public function testAFirstTrackWithDiagnosticsDisabledKeepsThemOff(): void #[Group('ac-01-11')] public function testALaterTrackCannotDisableDiagnosticsAndWarnsOnce(): void { - putenv('DISABLE_DIAGNOSTICS=false'); + putenv('AGENTCAT_DISABLE_DIAGNOSTICS=false'); $client = $this->client(new RecordingHttpChannel()); $client->track(TrackingFixture::builder(), self::PROJECT); @@ -127,7 +128,7 @@ public function testALaterTrackCannotDisableDiagnosticsAndWarnsOnce(): void /** D44 and spec conflict 9: the enabled gate runs before the diagnostics setup. */ public function testADisabledClientNeverSetsDiagnosticsUp(): void { - putenv('DISABLE_DIAGNOSTICS=false'); + 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); @@ -138,7 +139,7 @@ public function testADisabledClientNeverSetsDiagnosticsUp(): void /** `AgentCat::fake()`'s client: no telemetry, so no diagnostics either. */ public function testWithoutTheTelemetrySeamNoDiagnosticsAreSetUp(): void { - putenv('DISABLE_DIAGNOSTICS=false'); + 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); @@ -151,7 +152,7 @@ public function testWithoutTheTelemetrySeamNoDiagnosticsAreSetUp(): void #[MaximumDuration(5000)] public function testOnlyTheSentEventLineCarriesTheActorId(): void { - putenv('DISABLE_DIAGNOSTICS=false'); + putenv('AGENTCAT_DISABLE_DIAGNOSTICS=false'); $collector = MockCollector::start(); try { @@ -191,7 +192,7 @@ public function testOnlyTheSentEventLineCarriesTheActorId(): void #[Group('ac-06-62')] public function testShutdownSendsTheDiagnosticsOnceWithinItsBudget(): void { - putenv('DISABLE_DIAGNOSTICS=false'); + 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); diff --git a/tests/Integration/Delivery/SignalInCallbackTest.php b/tests/Integration/Delivery/SignalInCallbackTest.php index a8380ba..645e66e 100644 --- a/tests/Integration/Delivery/SignalInCallbackTest.php +++ b/tests/Integration/Delivery/SignalInCallbackTest.php @@ -68,7 +68,7 @@ public function testADrainInsideACurlCallbackDoesNotCrashTheProcess(): void $process = new Process( [PHP_BINARY, __DIR__ . '/../Fixtures/signal-in-callback.php', $events, $side, (string) self::ROUNDS], null, - ['HOME' => $this->dir, 'DIAGNOSTICS_ENDPOINT' => MockCollector::closedPortUrl()], + ['HOME' => $this->dir, 'AGENTCAT_DIAGNOSTICS_ENDPOINT' => MockCollector::closedPortUrl()], ); $process->setTimeout(20); @@ -100,7 +100,7 @@ public function testAnExitInsideACurlCallbackLeavesTheChannelUsableAtShutdown(): $process = new Process( [PHP_BINARY, __DIR__ . '/../Fixtures/exit-in-callback.php', $side], null, - ['HOME' => $this->dir, 'DIAGNOSTICS_ENDPOINT' => MockCollector::closedPortUrl()], + ['HOME' => $this->dir, 'AGENTCAT_DIAGNOSTICS_ENDPOINT' => MockCollector::closedPortUrl()], ); $process->setTimeout(10); diff --git a/tests/Integration/Diagnostics/HardenedHostTest.php b/tests/Integration/Diagnostics/HardenedHostTest.php index 5c31137..e09cc99 100644 --- a/tests/Integration/Diagnostics/HardenedHostTest.php +++ b/tests/Integration/Diagnostics/HardenedHostTest.php @@ -46,7 +46,7 @@ public function testDiagnosticsStartWithoutTheAttributesOfDisabledFunctions(): v [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, 'DIAGNOSTICS_ENDPOINT' => $collector, 'DISABLE_DIAGNOSTICS' => false, 'DIAGNOSTICS_TOKEN' => false, 'APP_ENV' => false], + ['HOME' => $this->dir, 'AGENTCAT_DIAGNOSTICS_ENDPOINT' => $collector, 'AGENTCAT_DISABLE_DIAGNOSTICS' => false, 'DISABLE_DIAGNOSTICS' => false, 'AGENTCAT_DIAGNOSTICS_TOKEN' => false, 'APP_ENV' => false], ); $process->setTimeout(10); diff --git a/tests/Unit/Internal/Diagnostics/DiagnosticsEndpointTest.php b/tests/Unit/Internal/Diagnostics/DiagnosticsEndpointTest.php index 6146cd7..c8c7648 100644 --- a/tests/Unit/Internal/Diagnostics/DiagnosticsEndpointTest.php +++ b/tests/Unit/Internal/Diagnostics/DiagnosticsEndpointTest.php @@ -19,8 +19,8 @@ final class DiagnosticsEndpointTest extends TestCase #[\Override] protected function setUp(): void { - $this->environment = Environment::clear('DIAGNOSTICS_ENDPOINT', 'DIAGNOSTICS_TOKEN'); - putenv('DIAGNOSTICS_ENDPOINT=' . MockCollector::closedPortUrl()); // fail closed (ruling P8) + $this->environment = Environment::clear('AGENTCAT_DIAGNOSTICS_ENDPOINT', 'AGENTCAT_DIAGNOSTICS_TOKEN', 'DIAGNOSTICS_ENDPOINT', 'DIAGNOSTICS_TOKEN'); + putenv('AGENTCAT_DIAGNOSTICS_ENDPOINT=' . MockCollector::closedPortUrl()); // fail closed (ruling P8) } #[\Override] @@ -32,7 +32,7 @@ protected function tearDown(): void #[Group('ac-06-54')] public function testWithoutVariablesTheDefaultCollectorAndTokenAreUsed(): void { - putenv('DIAGNOSTICS_ENDPOINT'); // this test posts nothing + 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()); @@ -52,28 +52,42 @@ public static function bases(): iterable #[Group('ac-06-55')] public function testTheVariableSetsTheCollector(string $base, string $expected): void { - putenv('DIAGNOSTICS_ENDPOINT=' . $base); + putenv('AGENTCAT_DIAGNOSTICS_ENDPOINT=' . $base); $this->assertSame($expected, DiagnosticsEndpoint::url()); } public function testTheVariableIsReadFromTheServerArrayToo(): void { - putenv('DIAGNOSTICS_ENDPOINT'); - $_SERVER['DIAGNOSTICS_ENDPOINT'] = 'https://fpm.example'; - $_SERVER['DIAGNOSTICS_TOKEN'] = 'fpm'; + 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('DIAGNOSTICS_TOKEN=t'); + putenv('AGENTCAT_DIAGNOSTICS_TOKEN=t'); $this->assertSame('t', DiagnosticsEndpoint::token()); - putenv('DIAGNOSTICS_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 index 70e0e42..b792a99 100644 --- a/tests/Unit/Internal/Diagnostics/DiagnosticsSwitchTest.php +++ b/tests/Unit/Internal/Diagnostics/DiagnosticsSwitchTest.php @@ -15,7 +15,7 @@ final class DiagnosticsSwitchTest extends TestCase #[Group('ac-01-60')] public function testWithoutOptionOrVariableOutsideTestsDiagnosticsAreOn(): void { - $this->assertTrue(DiagnosticsSwitch::enabled(false, null, false)); + $this->assertTrue(DiagnosticsSwitch::enabled(false, null, null, false)); } /** @return iterable */ @@ -32,8 +32,8 @@ public static function disablingValues(): iterable public function testATrueOrUnknownValueTurnsDiagnosticsOff(string $value): void { $this->assertSame(DiagnosticsSwitch::DISABLED, DiagnosticsSwitch::flag($value)); - $this->assertFalse(DiagnosticsSwitch::enabled(false, $value, false)); - $this->assertFalse(DiagnosticsSwitch::enabled(false, $value, true)); + $this->assertFalse(DiagnosticsSwitch::enabled(false, $value, null, false)); + $this->assertFalse(DiagnosticsSwitch::enabled(false, $value, null, true)); } /** @return iterable */ @@ -50,8 +50,8 @@ public static function enablingValues(): iterable public function testAFalseValueTurnsDiagnosticsOnEvenUnderATestRunner(string $value): void { $this->assertSame(DiagnosticsSwitch::FORCE_ENABLED, DiagnosticsSwitch::flag($value)); - $this->assertTrue(DiagnosticsSwitch::enabled(false, $value, true)); - $this->assertTrue(DiagnosticsSwitch::enabled(false, $value, false)); + $this->assertTrue(DiagnosticsSwitch::enabled(false, $value, null, true)); + $this->assertTrue(DiagnosticsSwitch::enabled(false, $value, null, false)); } /** @return iterable */ @@ -68,14 +68,27 @@ public static function unsetValues(): iterable public function testABlankValueLetsTheTestEnvironmentDecide(?string $value): void { $this->assertSame(DiagnosticsSwitch::UNSET, DiagnosticsSwitch::flag($value)); - $this->assertTrue(DiagnosticsSwitch::enabled(false, $value, false)); - $this->assertFalse(DiagnosticsSwitch::enabled(false, $value, true)); + $this->assertTrue(DiagnosticsSwitch::enabled(false, $value, null, false)); + $this->assertFalse(DiagnosticsSwitch::enabled(false, $value, null, true)); + } + + /** A10 (D50): `DISABLE_DIAGNOSTICS`, the unprefixed name, is read only while `AGENTCAT_DISABLE_DIAGNOSTICS` is blank. */ + #[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)); + $this->assertTrue(DiagnosticsSwitch::enabled(false, 'false', 'true', true), 'a set AGENTCAT_DISABLE_DIAGNOSTICS decides'); + $this->assertFalse(DiagnosticsSwitch::enabled(false, 'true', 'false', false)); } #[Group('ac-06-48')] public function testTheOptionWinsOverTheVariable(): void { - $this->assertFalse(DiagnosticsSwitch::enabled(true, 'false', false)); - $this->assertFalse(DiagnosticsSwitch::enabled(true, null, false)); + $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 index af3d662..d322318 100644 --- a/tests/Unit/Internal/Diagnostics/DiagnosticsTest.php +++ b/tests/Unit/Internal/Diagnostics/DiagnosticsTest.php @@ -32,6 +32,12 @@ final class DiagnosticsTest extends TestCase private const int WALL_MILLIS = 1_792_163_096_821; + /** Read by diagnostics (D50), and the unprefixed names an application can have for itself. */ + private const array VARIABLES = [ + 'AGENTCAT_DISABLE_DIAGNOSTICS', 'AGENTCAT_DIAGNOSTICS_ENDPOINT', 'AGENTCAT_DIAGNOSTICS_TOKEN', + 'DISABLE_DIAGNOSTICS', 'DIAGNOSTICS_ENDPOINT', 'DIAGNOSTICS_TOKEN', 'APP_ENV', + ]; + private Environment $environment; private MemoryLogger $logger; @@ -47,8 +53,9 @@ final class DiagnosticsTest extends TestCase #[\Override] protected function setUp(): void { - $this->environment = Environment::clear('DISABLE_DIAGNOSTICS', 'DIAGNOSTICS_ENDPOINT', 'DIAGNOSTICS_TOKEN', 'APP_ENV'); - putenv('DIAGNOSTICS_ENDPOINT=' . MockCollector::closedPortUrl()); // fail closed (ruling P8) + $this->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); @@ -67,7 +74,7 @@ protected function tearDown(): void #[Group('ac-06-54')] public function testAWarningLineIsPostedToTheDefaultCollector(): void { - putenv('DIAGNOSTICS_ENDPOINT'); // the default URL, with a recording channel (ruling F8) + putenv('AGENTCAT_DIAGNOSTICS_ENDPOINT'); // the default URL, with a recording channel (ruling F8) $this->init('proj_ABC'); Log::write('Warning: something'); @@ -361,7 +368,7 @@ public function waitForActivity(float $seconds): void }; try { - putenv('DIAGNOSTICS_ENDPOINT=file://' . $dir . '/v1/logs'); + putenv('AGENTCAT_DIAGNOSTICS_ENDPOINT=file://' . $dir . '/v1/logs'); $this->queue = $this->queueOn($channel); $this->init('proj_ABC'); Log::write('one'); @@ -385,7 +392,7 @@ public function waitForActivity(float $seconds): void public static function anyVariable(): iterable { yield 'unset' => [null]; - yield 'DISABLE_DIAGNOSTICS=false' => ['false']; + yield 'AGENTCAT_DISABLE_DIAGNOSTICS=false' => ['false']; } #[DataProvider('anyVariable')] @@ -394,7 +401,7 @@ public static function anyVariable(): iterable public function testTheDisableOptionBuffersAndSendsNothing(?string $variable): void { if ($variable !== null) { - putenv('DISABLE_DIAGNOSTICS=' . $variable); + putenv('AGENTCAT_DISABLE_DIAGNOSTICS=' . $variable); } $this->init('p', disabled: true); Log::write('x'); @@ -410,7 +417,7 @@ public function testTheDisableOptionBuffersAndSendsNothing(?string $variable): v #[Group('ac-01-62')] public function testATrueVariableTurnsDiagnosticsOff(): void { - putenv('DISABLE_DIAGNOSTICS=true'); + putenv('AGENTCAT_DISABLE_DIAGNOSTICS=true'); $this->init('p'); Log::write('x'); @@ -418,6 +425,42 @@ public function testATrueVariableTurnsDiagnosticsOff(): void $this->assertFalse($this->queue->hasWork()); } + /** A10 (D50): the unprefixed `DISABLE_DIAGNOSTICS` is only a fallback that can turn diagnostics off. */ + #[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->assertTrue(Diagnostics::enabled(), 'the AgentCat name, when set, decides'); + } + + /** 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); + } + #[Group('ac-06-53')] #[Group('ac-01-11')] public function testTheFirstInitDecides(): void @@ -443,12 +486,12 @@ public function testTheFirstInitDecides(): void #[Group('ac-06-53')] public function testALaterDisablingCallIsSilentWhenDiagnosticsNeverStarted(): void { - putenv('DISABLE_DIAGNOSTICS=true'); + putenv('AGENTCAT_DISABLE_DIAGNOSTICS=true'); $this->init('p'); $this->init('p', disabled: true); Diagnostics::reset(); - putenv('DISABLE_DIAGNOSTICS'); + putenv('AGENTCAT_DISABLE_DIAGNOSTICS'); $this->init('p', testEnvironment: true); $this->init('p', disabled: true); @@ -464,7 +507,7 @@ public function testATestEnvironmentTurnsDiagnosticsOffUnlessTheVariableIsFalse( $this->assertFalse(Diagnostics::enabled()); Diagnostics::reset(); - putenv('DISABLE_DIAGNOSTICS=false'); + putenv('AGENTCAT_DISABLE_DIAGNOSTICS=false'); $this->init('p', testEnvironment: true); $this->assertTrue(Diagnostics::enabled()); } @@ -481,7 +524,7 @@ public function testByDefaultThePhpUnitRunTurnsDiagnosticsOff(): void #[Group('ac-06-50')] public function testTheDefaultClocksStampTheRecordAndKeepTheWindowOpen(): void { - putenv('DISABLE_DIAGNOSTICS=false'); + putenv('AGENTCAT_DISABLE_DIAGNOSTICS=false'); Diagnostics::init('p', false, $this->queue); $before = (int) floor(microtime(true) * 1000); Log::write('x'); @@ -504,14 +547,14 @@ public function testTheDefaultClocksStampTheRecordAndKeepTheWindowOpen(): void public function testTheCollectorAndTokenAreReadAtEachFlush(): void { $this->init('proj_ABC'); - putenv('DIAGNOSTICS_ENDPOINT=https://c.example/'); - putenv('DIAGNOSTICS_TOKEN=t'); + putenv('AGENTCAT_DIAGNOSTICS_ENDPOINT=https://c.example/'); + putenv('AGENTCAT_DIAGNOSTICS_TOKEN=t'); Log::write('one'); $this->now += 2.0; $this->queue->pump(); - putenv('DIAGNOSTICS_ENDPOINT=https://c.example/v1/logs//'); - putenv('DIAGNOSTICS_TOKEN='); + putenv('AGENTCAT_DIAGNOSTICS_ENDPOINT=https://c.example/v1/logs//'); + putenv('AGENTCAT_DIAGNOSTICS_TOKEN='); Log::write('two'); $this->now += 2.0; $this->queue->pump(); diff --git a/tests/bootstrap.php b/tests/bootstrap.php index a4de75d..d394152 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -15,9 +15,14 @@ putenv('HOME=' . $agentcatTestHome); $_SERVER['HOME'] = $_ENV['HOME'] = $agentcatTestHome; -// TS `src/tests/setup.ts`: no test sends diagnostics unless it clears this; subprocesses inherit it. -putenv('DISABLE_DIAGNOSTICS=true'); -$_SERVER['DISABLE_DIAGNOSTICS'] = $_ENV['DISABLE_DIAGNOSTICS'] = 'true'; +// 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); From 81e33b804d1df98ef3ca390d81b76c4cce358a07 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Sun, 18 Oct 2026 04:18:42 +0000 Subject: [PATCH 31/35] fix: treat a null disableDiagnostics as not set and say when it counts 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 ; the value is treated as true" instead of "the default is used". --- src/Options.php | 16 +++++++++------- tests/Unit/OptionsTest.php | 10 +++++----- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/Options.php b/src/Options.php index d1bd9d5..b4e47b2 100644 --- a/src/Options.php +++ b/src/Options.php @@ -82,8 +82,8 @@ public function __construct( * * `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). Any other value, null included, logs the warning and turns diagnostics - * off: an opt-out fails closed. + * 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 */ @@ -131,16 +131,18 @@ private static function bool(array $config, string $key, bool $default): bool /** @param array $config */ private static function disableDiagnostics(array $config): bool { - if (!array_key_exists('disableDiagnostics', $config)) { - return false; + $value = $config['disableDiagnostics'] ?? null; + if ($value === null) { + return false; // not set: env() of an unset variable gives null } - $disabled = DiagnosticsSwitch::option($config['disableDiagnostics']); + $disabled = DiagnosticsSwitch::option($value); if ($disabled !== null) { return $disabled; } - self::reject('disableDiagnostics', 'bool', $config['disableDiagnostics']); + // 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; // a privacy opt-out fails closed + return true; } /** diff --git a/tests/Unit/OptionsTest.php b/tests/Unit/OptionsTest.php index 73a0404..d326403 100644 --- a/tests/Unit/OptionsTest.php +++ b/tests/Unit/OptionsTest.php @@ -274,8 +274,8 @@ public function testFromArrayRejectsWrongTelemetryOptionTypes(): void /** * 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 `DISABLE_DIAGNOSTICS` - * words, trimmed, in any case. + * 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 */ @@ -299,6 +299,7 @@ public static function diagnosticsSwitches(): iterable 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')] @@ -315,17 +316,16 @@ public static function unreadableDiagnosticsSwitches(): iterable yield 'another word' => ['disabled', 'string']; yield 'int 2' => [2, 'int']; yield 'float 1.0' => [1.0, 'float']; - yield "null (env() of a missing variable)" => [null, 'null']; yield 'an array' => [[], 'array']; } - /** A4: a privacy opt-out fails closed: a value it cannot read logs the usual line and turns diagnostics off. */ + /** 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 default is used'], + ['WARN: AgentCat option "disableDiagnostics" must be bool, got ' . $type . '; the value is treated as true'], $this->logger->messages(), ); } From d775c1dee624d633c2c2dcdd939549a0e034928c Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Sun, 18 Oct 2026 04:19:12 +0000 Subject: [PATCH 32/35] fix: give diagnostics requests a 250 ms connect and 1 s total timeout 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. --- src/Internal/Diagnostics/Diagnostics.php | 16 ++-- src/Internal/Transport/CurlHttpChannel.php | 16 ++-- src/Internal/Transport/HttpRequest.php | 8 +- src/Options.php | 2 +- .../Diagnostics/DiagnosticsTimeoutsTest.php | 84 +++++++++++++++++++ .../Internal/Diagnostics/DiagnosticsTest.php | 13 +++ .../Telemetry/TelemetryManagerTest.php | 4 + .../Transport/CurlHttpChannelUnitTest.php | 29 +++++++ 8 files changed, 159 insertions(+), 13 deletions(-) create mode 100644 tests/Integration/Diagnostics/DiagnosticsTimeoutsTest.php diff --git a/src/Internal/Diagnostics/Diagnostics.php b/src/Internal/Diagnostics/Diagnostics.php index ef360e9..52361e9 100644 --- a/src/Internal/Diagnostics/Diagnostics.php +++ b/src/Internal/Diagnostics/Diagnostics.php @@ -15,10 +15,11 @@ /** * TypeScript diagnostics: every SDK log line (D07), as OTLP/JSON logs to AgentCat's collector. The first `init()` of * the process decides. Lines are buffered (1 000 at most, with at most 1 MiB of bodies; the oldest are dropped) and - * posted as a background request (`flush()` does not wait for it) at the first queue pass (`pump()`, `flush()`, - * `shutdown()`) once the oldest of them is 2 s old, and once more when the queue shuts down, after the events (D47: - * under PHP-FPM one post per request, after the response). Lines logged in the last shutdown pass, after `finish()`, - * are dropped unless a later `shutdown()` runs. A failure is silent: a log line would feed the buffer it reports on. + * posted as a background request (`flush()` does not wait for it; 250 ms connect and 1 s total timeouts, D49) at the + * first queue pass (`pump()`, `flush()`, `shutdown()`) once the oldest of them is 2 s old, and once more when the queue + * shuts down, after the events (D47: under PHP-FPM one post per request, after the response). Lines logged in the last + * shutdown pass, after `finish()`, are dropped unless a later `shutdown()` runs. A failure is silent: a log line would + * feed the buffer it reports on. * * @internal */ @@ -31,6 +32,11 @@ final class Diagnostics implements QueueCompanion public const float BATCH_FLUSH_SECONDS = 2.0; + /** D49: a blocked collector holds a worker at most this long after the response (exporters keep 2 s and 10 s). */ + public const float CONNECT_TIMEOUT_SECONDS = 0.25; + + public const float TOTAL_TIMEOUT_SECONDS = 1.0; + private const string IGNORED = 'WARN: AgentCat diagnostics were set up by an earlier track(); disableDiagnostics: true in this call is ignored'; private static bool $initialized = false; @@ -184,7 +190,7 @@ private function flush(): void 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), + 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) { diff --git a/src/Internal/Transport/CurlHttpChannel.php b/src/Internal/Transport/CurlHttpChannel.php index 03a3180..0e7e566 100644 --- a/src/Internal/Transport/CurlHttpChannel.php +++ b/src/Internal/Transport/CurlHttpChannel.php @@ -9,7 +9,7 @@ /** * The second curl_multi handle of a queue: exporter and diagnostics requests, no retries (TypeScript sends each once), - * connect and total timeouts, http/https only, no redirects. `pump()` never blocks. PHP warnings are suppressed and + * connect and total timeouts (the request's, else the channel's), http/https only, no redirects. `pump()` never blocks. PHP warnings are suppressed and * every throwable is swallowed (D32; tests see them through `$onSwallowed`): a failed request reaches only its * callback. `CurlMulti` runs the transfers. * @@ -187,7 +187,7 @@ private function launch(): void $transfer = new HttpTransfer($next['onDone'], $next['request']->background); try { - $error = $this->multi->open($this->options($next['request'], $transfer), $transfer); + $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 } @@ -224,11 +224,14 @@ private function finish(\Closure $onDone, HttpResponse $response): void /** * The options of `$request`: the write and header functions first (`CurlSupport::postOptions()`). They hold only - * `$transfer`, so a finished handle frees it and no handle holds the channel. + * `$transfer`, so a finished handle frees it and no handle holds the channel. The request's timeouts win over the + * channel's (diagnostics: 250 ms and 1 s, D49). * * @return array + * + * @throws \InvalidArgumentException for a request timeout curl cannot take: that request fails */ - private function options(HttpRequest $request, HttpTransfer $transfer): array + public function curlOptions(HttpRequest $request, HttpTransfer $transfer): array { $headers = []; foreach ($request->headers as $name => $value) { @@ -254,6 +257,9 @@ private function options(HttpRequest $request, HttpTransfer $transfer): array }, ]; - return CurlSupport::postOptions($capture, $request->url, $request->body, $headers, $this->connectTimeoutMs, $this->totalTimeoutMs); + $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/HttpRequest.php b/src/Internal/Transport/HttpRequest.php index d60c757..a0fa74f 100644 --- a/src/Internal/Transport/HttpRequest.php +++ b/src/Internal/Transport/HttpRequest.php @@ -13,13 +13,17 @@ { /** * @param array $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 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/Options.php b/src/Options.php index b4e47b2..32ad85a 100644 --- a/src/Options.php +++ b/src/Options.php @@ -49,7 +49,7 @@ * 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. They are off in test environments. AGENTCAT_DIAGNOSTICS_ENDPOINT and AGENTCAT_DIAGNOSTICS_TOKEN - * change where they go. + * change where they go; a blocked collector holds a request at most 1 s. * @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 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/Unit/Internal/Diagnostics/DiagnosticsTest.php b/tests/Unit/Internal/Diagnostics/DiagnosticsTest.php index d322318..f0201c6 100644 --- a/tests/Unit/Internal/Diagnostics/DiagnosticsTest.php +++ b/tests/Unit/Internal/Diagnostics/DiagnosticsTest.php @@ -461,6 +461,19 @@ public function testTheApplicationsOwnCollectorVariablesAreIgnored(): void $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 diff --git a/tests/Unit/Internal/Telemetry/TelemetryManagerTest.php b/tests/Unit/Internal/Telemetry/TelemetryManagerTest.php index fe72d9a..9cb7ce7 100644 --- a/tests/Unit/Internal/Telemetry/TelemetryManagerTest.php +++ b/tests/Unit/Internal/Telemetry/TelemetryManagerTest.php @@ -75,6 +75,10 @@ public function testEveryConfigTypeBuildsItsExporterAndEveryExporterSends(): voi 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')] diff --git a/tests/Unit/Internal/Transport/CurlHttpChannelUnitTest.php b/tests/Unit/Internal/Transport/CurlHttpChannelUnitTest.php index be09a57..a94e895 100644 --- a/tests/Unit/Internal/Transport/CurlHttpChannelUnitTest.php +++ b/tests/Unit/Internal/Transport/CurlHttpChannelUnitTest.php @@ -8,6 +8,7 @@ use AgentCat\Internal\Transport\CurlHttpChannel; use AgentCat\Internal\Transport\HttpRequest; use AgentCat\Internal\Transport\HttpResponse; +use AgentCat\Internal\Transport\HttpTransfer; use AgentCat\Tests\Support\MemoryLogger; use AgentCat\Tests\Support\Thrown; use PHPUnit\Framework\Attributes\DataProvider; @@ -278,6 +279,34 @@ public function testARequestKeepsItsHeadersInOrder(): void $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 { From 7871965120cc8333629360c52081f9b0fdd5091a Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Sun, 18 Oct 2026 04:40:06 +0000 Subject: [PATCH 33/35] fix: let an off value of either diagnostics variable win 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. --- src/Internal/Diagnostics/DiagnosticsSwitch.php | 13 +++++-------- src/Options.php | 5 +++-- .../Diagnostics/DiagnosticsSwitchTest.php | 15 ++++++++++++--- .../Unit/Internal/Diagnostics/DiagnosticsTest.php | 4 ++-- 4 files changed, 22 insertions(+), 15 deletions(-) diff --git a/src/Internal/Diagnostics/DiagnosticsSwitch.php b/src/Internal/Diagnostics/DiagnosticsSwitch.php index 5062b1a..bdaeec8 100644 --- a/src/Internal/Diagnostics/DiagnosticsSwitch.php +++ b/src/Internal/Diagnostics/DiagnosticsSwitch.php @@ -8,8 +8,8 @@ /** * TypeScript `initDiagnostics`'s decision: the `disableDiagnostics` option, then `AGENTCAT_DISABLE_DIAGNOSTICS`, then - * the test environment. While `AGENTCAT_DISABLE_DIAGNOSTICS` is blank, the unprefixed `DISABLE_DIAGNOSTICS` (the - * TypeScript name) can turn diagnostics off, never on (D50). + * the test environment. The unprefixed `DISABLE_DIAGNOSTICS` (the TypeScript name) can turn diagnostics off, never on + * (D50). When the two disagree, off wins: a privacy opt-out fails closed. * * @internal */ @@ -68,15 +68,12 @@ public static function option(mixed $value): ?bool /** * @param ?string $variable `AGENTCAT_DISABLE_DIAGNOSTICS` - * @param ?string $fallback `DISABLE_DIAGNOSTICS`, read only while `$variable` is blank; it can only turn - * diagnostics off + * @param ?string $fallback `DISABLE_DIAGNOSTICS`: it can only turn diagnostics off, also over a false `$variable` */ public static function enabled(bool $disabledOption, ?string $variable, ?string $fallback, bool $testEnvironment): bool { - $flag = self::flag($variable); - if ($flag === self::UNSET && self::flag($fallback) === self::DISABLED) { - $flag = self::DISABLED; // a false value there never turns diagnostics on - } + // Off wins: a false value of either name never turns diagnostics on over the other's true. + $flag = self::flag($fallback) === self::DISABLED ? self::DISABLED : self::flag($variable); return !$disabledOption && $flag !== self::DISABLED && ($flag === self::FORCE_ENABLED || !$testEnvironment); } diff --git a/src/Options.php b/src/Options.php index 32ad85a..fc549d2 100644 --- a/src/Options.php +++ b/src/Options.php @@ -48,8 +48,9 @@ * 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. They are off in test environments. AGENTCAT_DIAGNOSTICS_ENDPOINT and AGENTCAT_DIAGNOSTICS_TOKEN - * change where they go; a blocked collector holds a request at most 1 s. + * 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; a blocked collector + * holds a request at most 1 s. * @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 diff --git a/tests/Unit/Internal/Diagnostics/DiagnosticsSwitchTest.php b/tests/Unit/Internal/Diagnostics/DiagnosticsSwitchTest.php index b792a99..ecc6ce4 100644 --- a/tests/Unit/Internal/Diagnostics/DiagnosticsSwitchTest.php +++ b/tests/Unit/Internal/Diagnostics/DiagnosticsSwitchTest.php @@ -72,7 +72,7 @@ public function testABlankValueLetsTheTestEnvironmentDecide(?string $value): voi $this->assertFalse(DiagnosticsSwitch::enabled(false, $value, null, true)); } - /** A10 (D50): `DISABLE_DIAGNOSTICS`, the unprefixed name, is read only while `AGENTCAT_DISABLE_DIAGNOSTICS` is blank. */ + /** A10 (D50): `DISABLE_DIAGNOSTICS`, the unprefixed name, can only turn diagnostics off. */ #[Group('ac-06-49')] public function testTheUnprefixedFallbackCanOnlyTurnDiagnosticsOff(): void { @@ -81,8 +81,17 @@ public function testTheUnprefixedFallbackCanOnlyTurnDiagnosticsOff(): void $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)); - $this->assertTrue(DiagnosticsSwitch::enabled(false, 'false', 'true', true), 'a set AGENTCAT_DISABLE_DIAGNOSTICS decides'); - $this->assertFalse(DiagnosticsSwitch::enabled(false, 'true', 'false', 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')] diff --git a/tests/Unit/Internal/Diagnostics/DiagnosticsTest.php b/tests/Unit/Internal/Diagnostics/DiagnosticsTest.php index f0201c6..57bca24 100644 --- a/tests/Unit/Internal/Diagnostics/DiagnosticsTest.php +++ b/tests/Unit/Internal/Diagnostics/DiagnosticsTest.php @@ -425,7 +425,7 @@ public function testATrueVariableTurnsDiagnosticsOff(): void $this->assertFalse($this->queue->hasWork()); } - /** A10 (D50): the unprefixed `DISABLE_DIAGNOSTICS` is only a fallback that can turn diagnostics off. */ + /** A10 (D50): the unprefixed `DISABLE_DIAGNOSTICS` can only turn diagnostics off, and its off wins. */ #[Group('ac-06-49')] public function testTheUnprefixedVariableCanOnlyTurnDiagnosticsOff(): void { @@ -442,7 +442,7 @@ public function testTheUnprefixedVariableCanOnlyTurnDiagnosticsOff(): void putenv('DISABLE_DIAGNOSTICS=true'); putenv('AGENTCAT_DISABLE_DIAGNOSTICS=false'); $this->init('p', testEnvironment: true); - $this->assertTrue(Diagnostics::enabled(), 'the AgentCat name, when set, decides'); + $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. */ From e760eeb02d18b9e7a0e22a45b2386d5d477b795e Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Sun, 18 Oct 2026 06:14:49 +0000 Subject: [PATCH 34/35] fix: keep the curl re-entrancy guard out of the event transport 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). --- src/Internal/Support/OnRelease.php | 22 ---- src/Internal/Transport/CallbackFlag.php | 16 +++ src/Internal/Transport/CurlHttpChannel.php | 111 +++++++++++----- src/Internal/Transport/CurlMulti.php | 102 ++++++++------- src/Options.php | 4 +- .../Delivery/EventQueueDeliveryTest.php | 6 +- .../Delivery/SignalAfterExecTest.php | 122 ++++++++++++++++++ .../Delivery/SignalInCallbackTest.php | 4 +- .../Integration/Fixtures/exit-in-callback.php | 9 +- .../Fixtures/signal-after-exec.php | 102 +++++++++++++++ .../Fixtures/signal-in-callback.php | 8 +- .../Transport/CurlHttpChannelTest.php | 79 ++++++++---- tests/Integration/Transport/CurlMultiTest.php | 105 +++++++++++---- 13 files changed, 528 insertions(+), 162 deletions(-) delete mode 100644 src/Internal/Support/OnRelease.php create mode 100644 src/Internal/Transport/CallbackFlag.php create mode 100644 tests/Integration/Delivery/SignalAfterExecTest.php create mode 100644 tests/Integration/Fixtures/signal-after-exec.php diff --git a/src/Internal/Support/OnRelease.php b/src/Internal/Support/OnRelease.php deleted file mode 100644 index d194875..0000000 --- a/src/Internal/Support/OnRelease.php +++ /dev/null @@ -1,22 +0,0 @@ -release)(); - } -} diff --git a/src/Internal/Transport/CallbackFlag.php b/src/Internal/Transport/CallbackFlag.php new file mode 100644 index 0000000..0c0f1cd --- /dev/null +++ b/src/Internal/Transport/CallbackFlag.php @@ -0,0 +1,16 @@ +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] @@ -99,15 +107,16 @@ public function post(HttpRequest $request, \Closure $onDone): void $this->waitingBytes += $bytes; } - /** Also logs the requests dropped since the last pass, in one line. */ + /** 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->waiting !== [] || $this->multi->count() > 0) { // else nothing posted (or nothing can be): no curl call + 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(); - $this->collect(); - $this->launch(); // requests that callbacks posted (Sentry's sequence) start in this pass + if ($this->collect()) { + $this->launch(); // requests that callbacks posted (Sentry's sequence) start in this pass + } }); } if ($this->dropped > 0) { @@ -116,11 +125,11 @@ public function pump(): void } } - /** 0 from inside one of its own transfer callbacks (a signal drain): nothing can progress there, so nothing can be waited for. */ + /** 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->multi->executing()) { + if ($this->busy()) { return 0; } if ($includeBackground) { @@ -140,8 +149,8 @@ public function pending(bool $includeBackground = true): int #[\Override] public function waitForActivity(float $seconds): void { - if ($this->multi->count() === 0) { - return; // a waiting request starts at the next pump() + 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 { @@ -155,6 +164,26 @@ 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 { @@ -175,13 +204,10 @@ private function swallowed(\Throwable $error): void } } - /** - * Starts waiting requests while slots are free; the next collect() runs their first curl_multi_exec(). From inside - * a transfer callback none starts: they wait for the next pump. - */ + /** Starts waiting requests while slots are free; the next collect() runs their first curl_multi_exec(). */ private function launch(): void { - while (!$this->multi->executing() && $this->multi->count() < self::MAX_CONCURRENT && $this->waiting !== []) { + 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); @@ -197,10 +223,14 @@ private function launch(): void } } - /** Runs the transfers, then runs the callback of every transfer that this run finished (or that a failed run dropped). */ - private function collect(): void + /** + * 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 { - $this->multi->run( + $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) @@ -210,6 +240,11 @@ 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 */ @@ -224,8 +259,14 @@ private function finish(\Closure $onDone, HttpResponse $response): void /** * The options of `$request`: the write and header functions first (`CurlSupport::postOptions()`). They hold only - * `$transfer`, so a finished handle frees it and no handle holds the channel. The request's timeouts win over the - * channel's (diagnostics: 250 ms and 1 s, D49). + * `$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`. PHP runs a + * pending signal at a function call or a backward jump, so a handler inside the callback always sees the flag. 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 * @@ -237,23 +278,33 @@ public function curlOptions(HttpRequest $request, HttpTransfer $transfer): array foreach ($request->headers as $name => $value) { $headers[] = $name . ': ' . $value; } - $onWrite = $this->onWrite; + $inCallback = $this->inCallback; + $onCallback = $this->onCallback; $capture = [ - CURLOPT_WRITEFUNCTION => static function (\CurlHandle $handle, string $data) use ($transfer, $onWrite): int { - if ($onWrite instanceof \Closure) { - $onWrite(); + CURLOPT_WRITEFUNCTION => static function (\CurlHandle $handle, string $data) use ($transfer, $inCallback, $onCallback): int { + $inCallback->active = true; + if ($onCallback instanceof \Closure) { + $onCallback('write'); } // 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 strlen($data); // a shorter return value would abort the transfer + return $taken; }, - CURLOPT_HEADERFUNCTION => static function (\CurlHandle $handle, string $line) use ($transfer): int { + CURLOPT_HEADERFUNCTION => static function (\CurlHandle $handle, string $line) use ($transfer, $inCallback, $onCallback): int { + if ($onCallback instanceof \Closure) { + $onCallback('header'); + } + $inCallback->active = true; if (preg_match(self::STATUS_LINE, $line, $match) === 1) { $transfer->reason = $match[1] ?? ''; } + $taken = strlen($line); + $inCallback->active = false; // last: see curlOptions() - return strlen($line); + return $taken; }, ]; diff --git a/src/Internal/Transport/CurlMulti.php b/src/Internal/Transport/CurlMulti.php index c0ec005..703da9b 100644 --- a/src/Internal/Transport/CurlMulti.php +++ b/src/Internal/Transport/CurlMulti.php @@ -4,8 +4,6 @@ namespace AgentCat\Internal\Transport; -use AgentCat\Internal\Support\OnRelease; - /** * The curl_multi engine of `CurlTransport` and `CurlHttpChannel`: one multi handle, made at the first use, and its * transfers, each with its caller's payload. It keeps the rules that stop a waiting caller from hanging: a transfer @@ -14,10 +12,10 @@ * the multi handle. The callers choose what a result means (retries, callbacks) and suppress PHP warnings. * * A signal handler runs between two opcodes, so also inside a PHP callback of `curl_multi_exec()` (the write and header - * functions of `CurlHttpChannel`; `CurlTransport` sets none). libcurl refuses every multi call from there - * (`CURLM_RECURSIVE_API_CALL`), and failing the transfers there would free a handle that libcurl still uses: PHP - * crashes when the callback returns. So while `curl_multi_exec()` runs (`executing()`), `run()` and `select()` return - * at once, and `CurlHttpChannel` starts no transfer: a SIGTERM drain in that callback leaves this handle alone. + * functions of `CurlHttpChannel`; `CurlTransport` sets none). libcurl answers every multi call from there with + * `CURLM_RECURSIVE_API_CALL`. Such an answer leaves every handle and transfer as it is: removing or failing a transfer + * there would free a handle that libcurl still uses, and PHP would crash when the callback returns. A refused add + * waits for the next `run()`. (`CurlHttpChannel` keeps its drains out of its callbacks; this rule is the safety net.) * * @template TPayload * @@ -25,6 +23,9 @@ */ final class CurlMulti { + /** libcurl's `CURLM_RECURSIVE_API_CALL` (7.59.0+), which PHP does not define: a multi call from inside a callback. */ + public const int RECURSIVE_API_CALL = 8; + /** Null until the first use. */ private ?\CurlMultiHandle $multi = null; @@ -34,8 +35,8 @@ final class CurlMulti /** @var \Closure(string, int): int */ private readonly \Closure $multiStatus; - /** True while `curl_multi_exec()` runs. */ - private bool $executing = false; + /** @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 @@ -47,9 +48,9 @@ public function __construct(?\Closure $multiStatus = null) } /** - * Starts a transfer with `$options`. Null when it runs; 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. Not while `executing()`: - * libcurl refuses the add from inside a callback, so a caller keeps its request waiting then. + * 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 @@ -66,43 +67,35 @@ public function open(array $options, mixed $payload): ?string // 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]; - $code = ($this->multiStatus)('curl_multi_add_handle', curl_multi_add_handle($this->multi(), $handle)); - if ($code === CURLM_OK) { - return null; - } - unset($this->transfers[$id]); - // PHP keeps the handle in its list of the multi handle even when libcurl refused it. - curl_multi_remove_handle($this->multi(), $handle); - return CurlSupport::multiError($code); + 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. While `executing()` (a call from inside a callback of the running - * `curl_multi_exec()`), it does nothing. + * 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): void + public function run(\Closure $finished, \Closure $failed): bool { - if ($this->executing) { - return; + foreach (array_intersect_key($this->transfers, $this->unadded) as $id => $transfer) { + $error = $this->add($id, $transfer['handle']); + if ($error !== null) { + $failed($transfer['payload'], $error); + } + } + $code = ($this->multiStatus)('curl_multi_exec', curl_multi_exec($this->multi(), $running)); + if ($code === self::RECURSIVE_API_CALL) { + return false; // from inside a callback of the running curl_multi_exec(): every transfer stays as it is } - $this->executing = true; - // Cleared when released below, and also when exit() in a signal handler inside a callback unwinds this frame - // (no finally block runs then): the shutdown functions can use the handle again. - $executing = new OnRelease(function (): void { - $this->executing = false; - }); - $result = curl_multi_exec($this->multi(), $running); - unset($executing); - $code = ($this->multiStatus)('curl_multi_exec', $result); 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)); @@ -112,7 +105,7 @@ public function run(\Closure $finished, \Closure $failed): void /** @var array{msg: int, result: int, handle: \CurlHandle}|false $info the documented shape */ $info = curl_multi_info_read($this->multi()); if ($info === false) { - return; + return true; } $id = spl_object_id($info['handle']); $transfer = $this->transfers[$id] ?? null; @@ -124,27 +117,15 @@ public function run(\Closure $finished, \Closure $failed): void } } - /** - * Blocks until a transfer can progress or `$seconds` pass (0 or more, see `CurlSupport::waitSeconds()`). Does no - * work. While `executing()`, it returns at once: nothing progresses until the callback returns. - */ + /** 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 ($this->executing) { - return; - } if (curl_multi_select($this->multi(), $seconds) === -1) { usleep(1000); // the wait failed: a caller's loop must not spin } } - /** True while `curl_multi_exec()` runs: a call from inside one of its callbacks (a signal handler) must leave the handle alone. */ - public function executing(): bool - { - return $this->executing; - } - - /** Transfers in the multi handle. */ + /** Transfers in the multi handle (or waiting for the next `run()` to add them). */ public function count(): int { return count($this->transfers); @@ -156,6 +137,31 @@ 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 + { + $code = ($this->multiStatus)('curl_multi_add_handle', curl_multi_add_handle($this->multi(), $handle)); + 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/Options.php b/src/Options.php index fc549d2..fe702f0 100644 --- a/src/Options.php +++ b/src/Options.php @@ -49,8 +49,8 @@ * 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; a blocked collector - * holds a request at most 1 s. + * 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 diff --git a/tests/Integration/Delivery/EventQueueDeliveryTest.php b/tests/Integration/Delivery/EventQueueDeliveryTest.php index bd964e5..bbfeb5f 100644 --- a/tests/Integration/Delivery/EventQueueDeliveryTest.php +++ b/tests/Integration/Delivery/EventQueueDeliveryTest.php @@ -192,7 +192,7 @@ public function export(Event $event): void } /** - * I2: a SIGTERM drain that runs inside a write callback of the side channel (the seam stands in for the signal + * 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. @@ -208,7 +208,7 @@ public function testADrainInsideASideTransferCallbackDeliversTheEventAndReturns( /** Seconds the drain took; null until it ran. */ public ?float $drained = null; }; - $channel = new CurlHttpChannel(onWrite: function () use ($state): void { + $channel = new CurlHttpChannel(onCallback: function () use ($state): void { if ($state->drained !== null || !$state->queue instanceof EventQueue) { return; } @@ -232,7 +232,7 @@ public function testADrainInsideASideTransferCallbackDeliversTheEventAndReturns( } while ($channel->pending() > 0); $this->assertIsFloat($state->drained, 'the drain ran inside a write callback'); - $this->assertLessThan(1.0, $state->drained, 'it did not wait for the side channel it interrupted (the budget is 5 s)'); + $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'); } diff --git a/tests/Integration/Delivery/SignalAfterExecTest.php b/tests/Integration/Delivery/SignalAfterExecTest.php new file mode 100644 index 0000000..aaed876 --- /dev/null +++ b/tests/Integration/Delivery/SignalAfterExecTest.php @@ -0,0 +1,122 @@ +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'); + } + + /** @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/SignalInCallbackTest.php b/tests/Integration/Delivery/SignalInCallbackTest.php index 645e66e..7b6c0f4 100644 --- a/tests/Integration/Delivery/SignalInCallbackTest.php +++ b/tests/Integration/Delivery/SignalInCallbackTest.php @@ -90,8 +90,8 @@ public function testADrainInsideACurlCallbackDoesNotCrashTheProcess(): void } /** - * A handler that calls exit() inside a write callback: PHP skips finally blocks then, but frees the frame, so the - * re-entrancy guard is cleared and the shutdown function can still send on the same channel. + * 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 diff --git a/tests/Integration/Fixtures/exit-in-callback.php b/tests/Integration/Fixtures/exit-in-callback.php index 6ce8c6f..249d848 100644 --- a/tests/Integration/Fixtures/exit-in-callback.php +++ b/tests/Integration/Fixtures/exit-in-callback.php @@ -11,14 +11,15 @@ require dirname(__DIR__, 3) . '/vendor/autoload.php'; // A signal handler that calls exit() inside a write callback of the side channel (a framework's SIGTERM handler often -// exits; the seam stands in for it). PHP skips every finally block on exit(), so the channel's re-entrancy guard must -// still be cleared: the shutdown function then sends one more request on the same channel. +// exits; the seam stands in for it). PHP skips every finally block on exit(), so the callback leaves the channel's busy +// flag set; the channel must see that no curl_multi_exec() runs any more: the shutdown function then sends one more +// request on the same channel. // Usage: exit-in-callback.php // Prints "exit", then from the shutdown function "after: " and "pending: ". $url = $argv[1] ?? ''; $exited = false; -$channel = new CurlHttpChannel(onWrite: static function () use (&$exited): void { - if (!$exited) { +$channel = new CurlHttpChannel(onCallback: static function (string $kind) use (&$exited): void { + if ($kind === 'write' && !$exited) { $exited = true; echo "exit\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-in-callback.php b/tests/Integration/Fixtures/signal-in-callback.php index 0fd1e7f..9bca6d8 100644 --- a/tests/Integration/Fixtures/signal-in-callback.php +++ b/tests/Integration/Fixtures/signal-in-callback.php @@ -17,7 +17,7 @@ // A CLI server whose side channel downloads large answers while SIGTERM arrives every 200 µs (a forked child sends it). // A signal handler runs between two opcodes, so also inside a write callback of curl_multi_exec(). Each SIGTERM that // lands there runs AgentCat's drain (SignalDrain::onSignal()), then the server's own handler, which only counts and -// returns (a graceful-stop flag): the process goes on. Before the re-entrancy guard (I2), such a drain re-entered the +// returns (a graceful-stop flag): the process goes on. Before the I2 fix, such a drain re-entered the // multi handle, and PHP crashed when the callback returned (exit 139). A SIGTERM elsewhere calls only the server's // handler: a drain there would send the side requests itself while the next signals wait, so few would land inside a // callback. (AgentCat installs its drain once per process; the fixture runs it on every signal of the case under test.) @@ -50,8 +50,10 @@ }; 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 $frame) { - if ($frame['function'] === 'curl_multi_exec') { + 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 diff --git a/tests/Integration/Transport/CurlHttpChannelTest.php b/tests/Integration/Transport/CurlHttpChannelTest.php index bfdfeb4..35b9559 100644 --- a/tests/Integration/Transport/CurlHttpChannelTest.php +++ b/tests/Integration/Transport/CurlHttpChannelTest.php @@ -240,11 +240,13 @@ public function testABackgroundRequestInTransferIsLeftOutOfTheForegroundCount(): } /** - * I2: a signal handler (the SIGTERM drain) that runs inside a write callback pumps, waits on and posts to the channel - * it interrupted. libcurl refuses every multi call from inside a callback; the nested calls leave the transfers - * alone, the new request waits for the next pump, and both complete. + * 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. The `header` seam runs where PHP 8.4+ runs a signal, at the callback's entry before + * it marks the channel busy: the nested pump's add and run are refused, the refusal marks the channel busy, and + * the request waits. The `write` seam runs inside the body, where the channel is busy already. Either way: nothing + * pending, no wait, nothing failed, and every request completes. */ - #[MaximumDuration(3000)] + #[MaximumDuration(5000)] public function testAPumpFromInsideATransferCallbackLeavesTheChannelAlone(): void { $url = $this->collector()->scenario([], ['status' => 200, 'body' => str_repeat('x', 200_000)]); @@ -256,31 +258,62 @@ public function testAPumpFromInsideATransferCallbackLeavesTheChannelAlone(): voi }; $nested = []; $channel = null; - $channel = $this->channel(onWrite: static function () use (&$channel, &$nested, $url, $record): void { - if ($nested !== [] || !$channel instanceof CurlHttpChannel) { + $channel = $this->channel(onCallback: static function (string $kind) use (&$channel, &$nested, $url, $record): void { + if (isset($nested[$kind]) || !$channel instanceof CurlHttpChannel) { return; } $begin = hrtime(true); - $channel->post(new HttpRequest($url . '/b', [], ''), $record('b')); + $channel->post(new HttpRequest($url . '/' . $kind, [], ''), $record($kind)); $channel->pump(); - $channel->waitForActivity(1.0); - $nested = [ - 'seconds' => (hrtime(true) - $begin) / 1e9, - 'pending' => $channel->pending(), - 'inTransfer' => $channel->inTransfer(), - ]; + 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, 2.0)); + $this->assertTrue($this->drain($channel, 3.0)); ksort($responses); - $this->assertSame(['a' => [200, null], 'b' => [200, null]], $responses, 'no curl error, no lost transfer'); - $this->assertSame(['seconds', 'pending', 'inTransfer'], array_keys($nested), 'the seam ran inside a write callback'); - $this->assertLessThan(0.5, $nested['seconds'] ?? INF, 'the nested wait returned at once'); - $this->assertSame(0, $nested['pending'] ?? null, 'inside a callback nothing can be waited for'); - $this->assertSame(1, $nested['inTransfer'] ?? null, 'b waited: libcurl refuses an add from inside a callback'); - $this->assertCount(2, $this->collector()->requests($url)); + $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'); } /** @@ -590,17 +623,17 @@ public function testAWaitWithANanOrNegativeBoundReturnsAtOnceDuringATransfer(): /** * @param (\Closure(string, int): int)|null $multiStatus - * @param (\Closure(): void)|null $onWrite + * @param (\Closure(string): void)|null $onCallback */ private function channel( float $connectTimeoutSeconds = CurlTransport::CONNECT_TIMEOUT_SECONDS, float $totalTimeoutSeconds = CurlTransport::TOTAL_TIMEOUT_SECONDS, ?\Closure $multiStatus = null, - ?\Closure $onWrite = null, + ?\Closure $onCallback = null, ): CurlHttpChannel { return new CurlHttpChannel($connectTimeoutSeconds, $totalTimeoutSeconds, multiStatus: $multiStatus, onSwallowed: function (\Throwable $error): void { $this->swallowed[] = $error; - }, onWrite: $onWrite); + }, onCallback: $onCallback); } private function drain(HttpChannel $channel, float $seconds): bool diff --git a/tests/Integration/Transport/CurlMultiTest.php b/tests/Integration/Transport/CurlMultiTest.php index 414ac1e..36e5e11 100644 --- a/tests/Integration/Transport/CurlMultiTest.php +++ b/tests/Integration/Transport/CurlMultiTest.php @@ -28,35 +28,40 @@ public static function tearDownAfterClass(): void } /** - * I2: a signal handler runs between two opcodes, so also inside a PHP write callback of `curl_multi_exec()`. A - * drain there calls the engine again: libcurl refuses every multi call (CURLM_RECURSIVE_API_CALL), and failing the - * transfers would free a handle that libcurl still uses (a segfault). The nested calls must return at once and - * leave the transfers alone. The test keeps every handle alive, so that a regression fails an assertion instead of - * crashing PHPUnit. + * 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(3000)] - public function testACallFromInsideATransferCallbackLeavesTheTransfersAlone(): void + #[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(); + $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, $multi): void { - $finished[] = [$payload, $result, $multi->executing()]; + $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, $onFinished, $onFailed, &$handles, &$nested): int { + $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['executing'] = $multi->executing(); - $multi->run($onFinished, $onFailed); - $multi->select(1.0); + $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(); } @@ -65,20 +70,70 @@ public function testACallFromInsideATransferCallbackLeavesTheTransfersAlone(): v }; $this->assertNull($multi->open([CURLOPT_URL => $url . '/a', CURLOPT_WRITEFUNCTION => $write], 'a')); - $until = microtime(true) + 2.0; + $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 transfer did not finish'); - $multi->run($onFinished, $onFailed); + $this->assertLessThan($until, microtime(true), 'the transfers did not finish'); + $multi->run($finished, $failed); $multi->select(0.05); } - - $this->assertSame([], $failed, 'no transfer failed'); - $this->assertSame([['a', CURLE_OK, false]], $finished, 'the transfer completed once, after curl_multi_exec() returned'); - $this->assertSame(['executing', 'seconds', 'count'], array_keys($nested), 'the write callback ran'); - $this->assertTrue($nested['executing'] ?? null); - $this->assertLessThan(0.5, $nested['seconds'] ?? INF, 'the nested select returned at once'); - $this->assertSame(1, $nested['count'] ?? null, 'the transfer stayed in the multi handle'); - $this->assertFalse($multi->executing()); } private function collector(): MockCollector From b6c836b2ef450520d091594b14aade0aa214d3b6 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Sun, 18 Oct 2026 07:13:07 +0000 Subject: [PATCH 35/35] fix: never nest a curl call in another call's arguments 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. --- src/Internal/Transport/CurlHttpChannel.php | 38 +++++---- src/Internal/Transport/CurlMulti.php | 10 ++- src/Internal/Transport/CurlSupport.php | 4 +- .../Delivery/SignalAfterExecTest.php | 33 ++++++++ .../Integration/Fixtures/throw-after-exec.php | 81 +++++++++++++++++++ .../Transport/CurlHttpChannelTest.php | 16 ++-- 6 files changed, 156 insertions(+), 26 deletions(-) create mode 100644 tests/Integration/Fixtures/throw-after-exec.php diff --git a/src/Internal/Transport/CurlHttpChannel.php b/src/Internal/Transport/CurlHttpChannel.php index 23bfbc6..c18af66 100644 --- a/src/Internal/Transport/CurlHttpChannel.php +++ b/src/Internal/Transport/CurlHttpChannel.php @@ -9,14 +9,15 @@ /** * The second curl_multi handle of a queue: exporter and diagnostics requests, no retries (TypeScript sends each once), - * connect and total timeouts (the request's, else the channel's), http/https only, no redirects. `pump()` never blocks. PHP warnings are suppressed and - * every throwable is swallowed (D32; tests see them through `$onSwallowed`): a failed request reaches only its - * callback. `CurlMulti` runs the transfers. + * connect and total timeouts (the request's, else the channel's), http/https only, no redirects. `pump()` never + * blocks. PHP warnings are suppressed and every throwable is swallowed (D32; tests see them through `$onSwallowed`): a + * failed request reaches only its callback. `CurlMulti` runs the transfers. * * At most 1 000 requests with at most 16 MiB of bodies wait; the oldest are dropped, with one line per pass. While one * of its PHP callbacks runs (`busy()`), a signal handler there (the SIGTERM drain) gets nothing pending and its pumps - * and waits do nothing: libcurl refuses every multi call from inside a callback. PHP 8.4+ also runs a pending signal - * at the entry of a callback, before it marks the channel busy; the first refused `curl_multi_exec()` marks it then. + * and waits do nothing: libcurl refuses every multi call from inside a callback. Each callback marks the channel busy + * in its first statement; only PHP 8.4+ can run a signal before that, at the callback's entry, and there the first + * refused `curl_multi_exec()` marks the channel busy (`curlOptions()`). * * @internal */ @@ -68,10 +69,10 @@ final class CurlHttpChannel implements HttpChannel * @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): void)|null $onCallback tests only: where a signal handler can run, with `header` at the - * very start of every header callback, before it marks the - * channel busy (PHP 8.4+ runs signals there), and with - * `write` inside every write callback + * @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()`) */ @@ -262,11 +263,14 @@ private function finish(\Closure $onDone, HttpResponse $response): void * `$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`. PHP runs a - * pending signal at a function call or a backward jump, so a handler inside the callback always sees the flag. 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. + * 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 * @@ -284,7 +288,7 @@ public function curlOptions(HttpRequest $request, HttpTransfer $transfer): array CURLOPT_WRITEFUNCTION => static function (\CurlHandle $handle, string $data) use ($transfer, $inCallback, $onCallback): int { $inCallback->active = true; if ($onCallback instanceof \Closure) { - $onCallback('write'); + $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)); @@ -294,10 +298,10 @@ public function curlOptions(HttpRequest $request, HttpTransfer $transfer): array return $taken; }, CURLOPT_HEADERFUNCTION => static function (\CurlHandle $handle, string $line) use ($transfer, $inCallback, $onCallback): int { + $inCallback->active = true; if ($onCallback instanceof \Closure) { - $onCallback('header'); + $onCallback('header', $inCallback); } - $inCallback->active = true; if (preg_match(self::STATUS_LINE, $line, $match) === 1) { $transfer->reason = $match[1] ?? ''; } diff --git a/src/Internal/Transport/CurlMulti.php b/src/Internal/Transport/CurlMulti.php index 703da9b..08d1a99 100644 --- a/src/Internal/Transport/CurlMulti.php +++ b/src/Internal/Transport/CurlMulti.php @@ -17,6 +17,10 @@ * there would free a handle that libcurl still uses, and PHP would crash when the callback returns. A refused add * waits for the next `run()`. (`CurlHttpChannel` keeps its drains out of its callbacks; this rule is the safety net.) * + * PHP runs a signal that arrived during a `curl_*` call right after the call returns. Each such call is a statement of + * its own, never an argument of another call: on PHP 8.3, a handler that throws or calls `exit()` there, while an + * outer call is still being set up, corrupts the frame (a crash, a hang or an out-of-memory error). + * * @template TPayload * * @internal @@ -88,7 +92,8 @@ public function run(\Closure $finished, \Closure $failed): bool $failed($transfer['payload'], $error); } } - $code = ($this->multiStatus)('curl_multi_exec', curl_multi_exec($this->multi(), $running)); + $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 } @@ -143,7 +148,8 @@ public function payloads(): array */ private function add(int $id, \CurlHandle $handle): ?string { - $code = ($this->multiStatus)('curl_multi_add_handle', curl_multi_add_handle($this->multi(), $handle)); + $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]); diff --git a/src/Internal/Transport/CurlSupport.php b/src/Internal/Transport/CurlSupport.php index 94492d4..d0da7c9 100644 --- a/src/Internal/Transport/CurlSupport.php +++ b/src/Internal/Transport/CurlSupport.php @@ -135,6 +135,8 @@ public static function transferError(\CurlHandle $handle, int $result): string /** `cURL multi error : ` for a `curl_multi_*` call that did not return `CURLM_OK`. */ public static function multiError(int $code): string { - return sprintf('cURL multi error %d: %s', $code, curl_multi_strerror($code)); + $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/tests/Integration/Delivery/SignalAfterExecTest.php b/tests/Integration/Delivery/SignalAfterExecTest.php index aaed876..6711314 100644 --- a/tests/Integration/Delivery/SignalAfterExecTest.php +++ b/tests/Integration/Delivery/SignalAfterExecTest.php @@ -100,6 +100,39 @@ public function testADrainRightAfterCurlMultiExecDeliversTheEventAndItsExport(st $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 { 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/Transport/CurlHttpChannelTest.php b/tests/Integration/Transport/CurlHttpChannelTest.php index 35b9559..f5914f9 100644 --- a/tests/Integration/Transport/CurlHttpChannelTest.php +++ b/tests/Integration/Transport/CurlHttpChannelTest.php @@ -6,6 +6,7 @@ use AgentCat\Internal\Logging\Log; use AgentCat\Internal\Support\ErrorText; +use AgentCat\Internal\Transport\CallbackFlag; use AgentCat\Internal\Transport\CurlHttpChannel; use AgentCat\Internal\Transport\CurlTransport; use AgentCat\Internal\Transport\HttpChannel; @@ -241,10 +242,10 @@ public function testABackgroundRequestInTransferIsLeftOutOfTheForegroundCount(): /** * 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. The `header` seam runs where PHP 8.4+ runs a signal, at the callback's entry before - * it marks the channel busy: the nested pump's add and run are refused, the refusal marks the channel busy, and - * the request waits. The `write` seam runs inside the body, where the channel is busy already. Either way: nothing - * pending, no wait, nothing failed, and every request completes. + * 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 @@ -258,10 +259,13 @@ public function testAPumpFromInsideATransferCallbackLeavesTheChannelAlone(): voi }; $nested = []; $channel = null; - $channel = $this->channel(onCallback: static function (string $kind) use (&$channel, &$nested, $url, $record): void { + $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(); @@ -623,7 +627,7 @@ public function testAWaitWithANanOrNegativeBoundReturnsAtOnceDuringATransfer(): /** * @param (\Closure(string, int): int)|null $multiStatus - * @param (\Closure(string): void)|null $onCallback + * @param (\Closure(string, CallbackFlag): void)|null $onCallback */ private function channel( float $connectTimeoutSeconds = CurlTransport::CONNECT_TIMEOUT_SECONDS,