Skip to content

Commit 8644004

Browse files
committed
Prepare v1.8.0 release
Attach contexts.db (SQLSTATE, vendor code, scrubbed SQL) on database exceptions without requiring Doctrine instrumentation.
1 parent b81a795 commit 8644004

8 files changed

Lines changed: 287 additions & 3 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ This bundle is **FrankenPHP worker mode friendly**.
2626
- Breadcrumbs (`addBreadcrumb`) and performance transactions (`captureTransaction`)
2727
- Public **tags** API (`setTag` / `setTags`) and optional **`before_send`** scrubbing hook
2828
- Opt-in Doctrine SQL + HttpClient request spans / breadcrumbs (`instrumentation.*`)
29+
- Database exceptions attach `contexts.db` (SQLSTATE / SQL) without requiring Doctrine instrumentation
2930
- Optional console / Messenger failure listeners (nested console extras; optional Scheduler `ScheduledStamp` context) and optional Monolog handler
3031
- Optional automatic HTTP request transactions (`auto_http_transaction`)
3132
- Console command `nowo:beacon:test` to probe DSN connectivity (sync Envelope; `--check-only` available)

composer.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@
8484
},
8585
"config": {
8686
"sort-packages": true,
87-
"root-version": "1.7.3",
87+
"root-version": "1.8.0",
8888
"allow-plugins": {
8989
"phpstan/extension-installer": true
9090
},
@@ -95,7 +95,7 @@
9595
"prefer-stable": true,
9696
"extra": {
9797
"branch-alias": {
98-
"dev-main": "1.7.x-dev"
98+
"dev-main": "1.8.x-dev"
9999
}
100100
},
101101
"scripts": {

docs/CHANGELOG.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
77
## Table of contents
88

99
- [[Unreleased]](#unreleased)
10+
- [[1.8.0] - 2026-08-26](#180-2026-08-26)
11+
- [[1.7.8] - 2026-08-24](#178-2026-08-24)
1012
- [[1.7.7] - 2026-08-20](#177-2026-08-20)
1113
- [[1.7.6] - 2026-08-20](#176-2026-08-20)
1214
- [[1.7.5] - 2026-08-19](#175-2026-08-19)
@@ -97,6 +99,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
9799

98100
## [Unreleased]
99101

102+
## [1.8.0] - 2026-08-26
103+
104+
### Added
105+
106+
- **`contexts.db`** on captured database exceptions: SQLSTATE, vendor code, driver hint, `sql_mode`, and SQL (quoted literals scrubbed, max 8 KiB). Built from `PDOException::$errorInfo`, duck-typed Doctrine `getSQLState()` / `getQuery()`, and Laravel-style `(SQL: …)` messages. Does **not** require `instrumentation.doctrine`. Symfony Beacon **107** renders this as the Query panel.
107+
108+
### Notes
109+
110+
- Additive Envelope field. Older Beacon servers ignore unknown `contexts` keys.
111+
- No YAML changes.
112+
113+
[1.8.0]: https://github.com/nowo-tech/BeaconBundle/releases/tag/v1.8.0
100114

101115
## [1.7.8] - 2026-08-24
102116

@@ -508,7 +522,7 @@ Improve test coverage for trace, fatal, and console code paths (REQ-TEST-003).
508522
- Expanded documentation set for installation, configuration, usage, release, security, performance, Engram, and Spec Kit workflows.
509523
- Demo routes covering message capture, manual exception capture, listener-triggered exceptions, ignored exceptions, fingerprints, and runtime status.
510524

511-
[Unreleased]: https://github.com/nowo-tech/BeaconBundle/compare/v1.7.4...HEAD
525+
[Unreleased]: https://github.com/nowo-tech/BeaconBundle/compare/v1.8.0...HEAD
512526
[1.7.2]: https://github.com/nowo-tech/BeaconBundle/compare/v1.7.0...v1.7.2
513527
[1.6.11]: https://github.com/nowo-tech/BeaconBundle/compare/v1.6.10...v1.6.11
514528
[1.6.10]: https://github.com/nowo-tech/BeaconBundle/compare/v1.6.9...v1.6.10

docs/USAGE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ nowo_beacon:
4848
- InvalidArgumentException
4949
```
5050
51+
Database exceptions (PDO, Doctrine DBAL when present, or `SQLSTATE[…]` in the message) also attach **`contexts.db`** (SQLSTATE, vendor code, scrubbed SQL). Symfony Beacon 107+ shows that as the Query panel. Opt-in `instrumentation.doctrine` breadcrumbs remain separate.
52+
5153
## Manual reporting
5254

5355
```php
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Nowo\BeaconBundle\Context;
6+
7+
use PDOException;
8+
use Throwable;
9+
10+
use function is_array;
11+
use function is_int;
12+
use function is_object;
13+
use function is_string;
14+
use function mb_substr;
15+
use function method_exists;
16+
use function preg_match;
17+
use function preg_replace;
18+
use function strtolower;
19+
use function strtoupper;
20+
use function trim;
21+
22+
/**
23+
* Best-effort SQL/SQLSTATE facts from a throwable chain for Envelope {@code contexts.db}.
24+
*
25+
* Does not require doctrine/dbal; uses {@see PDOException} and duck-typed DBAL APIs.
26+
*/
27+
final class DatabaseExceptionContext
28+
{
29+
public const int MAX_SQL_LENGTH = 8192;
30+
31+
/**
32+
* @return array<string, mixed>|null
33+
*/
34+
public static function fromThrowable(Throwable $throwable): ?array
35+
{
36+
$merged = [];
37+
$current = $throwable;
38+
while ($current instanceof Throwable) {
39+
$merged = self::merge($merged, self::fromOne($current));
40+
$current = $current->getPrevious();
41+
}
42+
43+
if ($merged === []) {
44+
return null;
45+
}
46+
47+
$out = ['type' => 'sql'];
48+
foreach (['sqlstate', 'code', 'driver', 'sql', 'sql_mode'] as $key) {
49+
if (isset($merged[$key]) && is_string($merged[$key]) && $merged[$key] !== '') {
50+
$out[$key] = $merged[$key];
51+
}
52+
}
53+
if (isset($merged['bindings']) && is_array($merged['bindings']) && $merged['bindings'] !== []) {
54+
$out['bindings'] = $merged['bindings'];
55+
}
56+
57+
if (!isset($out['sqlstate']) && !isset($out['code']) && !isset($out['sql'])) {
58+
return null;
59+
}
60+
61+
return $out;
62+
}
63+
64+
/**
65+
* @return array<string, mixed>
66+
*/
67+
private static function fromOne(Throwable $throwable): array
68+
{
69+
$parsed = self::parseMessage($throwable->getMessage());
70+
71+
if ($throwable instanceof PDOException) {
72+
$info = $throwable->errorInfo;
73+
if (is_array($info)) {
74+
if (isset($info[0]) && is_string($info[0]) && $info[0] !== '' && $info[0] !== '00000') {
75+
$parsed['sqlstate'] = strtoupper($info[0]);
76+
}
77+
if (isset($info[1]) && (is_int($info[1]) || is_string($info[1]))) {
78+
$code = trim((string) $info[1]);
79+
if ($code !== '') {
80+
$parsed['code'] = $code;
81+
}
82+
}
83+
}
84+
$parsed['driver'] = $parsed['driver'] ?? 'pdo';
85+
}
86+
87+
if (method_exists($throwable, 'getSQLState')) {
88+
$state = $throwable->getSQLState();
89+
if (is_string($state) && $state !== '') {
90+
$parsed['sqlstate'] = strtoupper($state);
91+
}
92+
}
93+
94+
if (method_exists($throwable, 'getQuery')) {
95+
$query = $throwable->getQuery();
96+
if (is_string($query) && $query !== '') {
97+
$parsed['sql'] = self::scrubSql($query);
98+
} elseif (is_object($query) && method_exists($query, 'getSQL')) {
99+
$sql = $query->getSQL();
100+
if (is_string($sql) && $sql !== '') {
101+
$parsed['sql'] = self::scrubSql($sql);
102+
}
103+
}
104+
}
105+
106+
return $parsed;
107+
}
108+
109+
/**
110+
* @return array<string, mixed>
111+
*/
112+
private static function parseMessage(string $message): array
113+
{
114+
$out = [];
115+
if (preg_match('/SQLSTATE\[([A-Z0-9]{5})\]/i', $message, $m) === 1) {
116+
$out['sqlstate'] = strtoupper($m[1]);
117+
}
118+
if (preg_match('/SQLSTATE\[[A-Z0-9]{5}\]\s*:[^:]*:\s*(\d+)/i', $message, $m) === 1) {
119+
$out['code'] = $m[1];
120+
} elseif (preg_match('/\((\d{3,5}),\s*[\'"]([^\'"]+)[\'"]\)/', $message, $m) === 1) {
121+
$out['code'] = $m[1];
122+
}
123+
if (preg_match('/sql_mode\s*=\s*[\'"]?([^\s,;\'")]+)/i', $message, $m) === 1) {
124+
$out['sql_mode'] = $m[1];
125+
}
126+
if (preg_match('/Connection:\s*([a-z0-9_]+)/i', $message, $m) === 1) {
127+
$out['driver'] = strtolower($m[1]);
128+
}
129+
if (preg_match('/\(SQL:\s*(.+)\)\s*$/s', $message, $m) === 1 || preg_match('/,\s*SQL:\s*(.+)\)\s*$/s', $message, $m) === 1) {
130+
$out['sql'] = self::scrubSql(trim($m[1]));
131+
}
132+
133+
return $out;
134+
}
135+
136+
public static function scrubSql(string $sql): string
137+
{
138+
$collapsed = preg_replace('/\s+/', ' ', trim($sql));
139+
$sql = is_string($collapsed) ? $collapsed : trim($sql);
140+
$scrubbed = preg_replace("/'([^'\\\\]|\\\\.)*'/", "'?'", $sql);
141+
$sql = is_string($scrubbed) ? $scrubbed : $sql;
142+
143+
return mb_substr($sql, 0, self::MAX_SQL_LENGTH);
144+
}
145+
146+
/**
147+
* @param array<string, mixed> $base
148+
* @param array<string, mixed> $overlay
149+
*
150+
* @return array<string, mixed>
151+
*/
152+
private static function merge(array $base, array $overlay): array
153+
{
154+
foreach ($overlay as $key => $value) {
155+
if ($value === null || $value === '') {
156+
continue;
157+
}
158+
$base[$key] = $value;
159+
}
160+
161+
return $base;
162+
}
163+
}

src/Envelope/EnvelopeBuilder.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use DateTimeImmutable;
88
use DateTimeZone;
99
use Nowo\BeaconBundle\Breadcrumb\BreadcrumbBuffer;
10+
use Nowo\BeaconBundle\Context\DatabaseExceptionContext;
1011
use Nowo\BeaconBundle\Context\UserContextProviderInterface;
1112
use Nowo\BeaconBundle\Dsn\BeaconDsn;
1213
use Nowo\BeaconBundle\Scope\Scope;
@@ -112,6 +113,12 @@ public function buildEventEnvelope(
112113
}
113114

114115
$contexts = $this->buildContexts();
116+
if ($throwable instanceof Throwable) {
117+
$db = DatabaseExceptionContext::fromThrowable($throwable);
118+
if ($db !== null) {
119+
$contexts['db'] = $db;
120+
}
121+
}
115122
if ($contexts !== []) {
116123
$payload['contexts'] = $contexts;
117124
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Nowo\BeaconBundle\Tests\Unit\Context;
6+
7+
use Nowo\BeaconBundle\Context\DatabaseExceptionContext;
8+
use PDOException;
9+
use PHPUnit\Framework\TestCase;
10+
use RuntimeException;
11+
12+
final class DatabaseExceptionContextTest extends TestCase
13+
{
14+
public function testExtractsSqlstateAndSqlFromLaravelStyleMessage(): void
15+
{
16+
$sql = "select `id` from `attendances` where `email` = 'secret@example.test' group by `date`";
17+
$message = 'SQLSTATE[42000]: Syntax error or access violation: 1055 Expression #1; sql_mode=only_full_group_by (Connection: mysql, SQL: ' . $sql . ')';
18+
$ctx = DatabaseExceptionContext::fromThrowable(new RuntimeException($message));
19+
20+
self::assertIsArray($ctx);
21+
self::assertSame('sql', $ctx['type']);
22+
self::assertSame('42000', $ctx['sqlstate']);
23+
self::assertSame('1055', $ctx['code']);
24+
self::assertSame('mysql', $ctx['driver']);
25+
self::assertSame('only_full_group_by', $ctx['sql_mode']);
26+
self::assertIsString($ctx['sql']);
27+
self::assertStringContainsString('attendances', $ctx['sql']);
28+
self::assertStringNotContainsString('secret@example.test', $ctx['sql']);
29+
self::assertStringContainsString("'?'", $ctx['sql']);
30+
}
31+
32+
public function testUsesPdoErrorInfo(): void
33+
{
34+
$pdo = new PDOException('SQLSTATE[HY000] [1040] Too many connections');
35+
$pdo->errorInfo = ['HY000', 1040, 'Too many connections'];
36+
37+
$ctx = DatabaseExceptionContext::fromThrowable($pdo);
38+
self::assertIsArray($ctx);
39+
self::assertSame('HY000', $ctx['sqlstate']);
40+
self::assertSame('1040', $ctx['code']);
41+
self::assertSame('pdo', $ctx['driver']);
42+
}
43+
44+
public function testReturnsNullForNonDatabaseThrowable(): void
45+
{
46+
self::assertNull(DatabaseExceptionContext::fromThrowable(new RuntimeException('plain boom')));
47+
}
48+
49+
public function testDuckTypedDbalQueryWinsForSql(): void
50+
{
51+
$inner = new class('SQLSTATE[23505]: Unique violation') extends RuntimeException {
52+
public function getSQLState(): string
53+
{
54+
return '23505';
55+
}
56+
57+
public function getQuery(): object
58+
{
59+
return new class {
60+
public function getSQL(): string
61+
{
62+
return "INSERT INTO users (email) VALUES ('a@b.c')";
63+
}
64+
};
65+
}
66+
};
67+
68+
$ctx = DatabaseExceptionContext::fromThrowable(new RuntimeException('wrapper', 0, $inner));
69+
self::assertIsArray($ctx);
70+
self::assertSame('23505', $ctx['sqlstate']);
71+
self::assertIsString($ctx['sql']);
72+
self::assertStringContainsString('INSERT INTO users', $ctx['sql']);
73+
self::assertStringNotContainsString('a@b.c', $ctx['sql']);
74+
}
75+
76+
public function testScrubSqlTruncates(): void
77+
{
78+
$sql = DatabaseExceptionContext::scrubSql(str_repeat('SELECT 1, ', 2000));
79+
self::assertSame(DatabaseExceptionContext::MAX_SQL_LENGTH, mb_strlen($sql));
80+
}
81+
}

tests/Unit/Envelope/EnvelopeBuilderTest.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
use Nowo\BeaconBundle\Envelope\SendOptions;
1313
use Nowo\BeaconBundle\Scope\Scope;
1414
use Nowo\BeaconBundle\Trace\TraceIdProvider;
15+
use PDOException;
1516
use PHPUnit\Framework\TestCase;
1617
use ReflectionMethod;
1718
use RuntimeException;
@@ -70,6 +71,21 @@ public function testBuildsNdjsonEnvelopeWithExceptionExtraAndFingerprint(): void
7071
self::assertArrayNotHasKey('user', $payload);
7172
}
7273

74+
public function testAttachesContextsDbForDatabaseException(): void
75+
{
76+
$dsn = (new BeaconDsnParser())->parse('https://pubkey:secret@localhost:9444/1');
77+
$pdo = new PDOException('SQLSTATE[42000]: Syntax error or access violation: 1055 (SQL: select id from t)');
78+
$pdo->errorInfo = ['42000', 1055, 'syntax'];
79+
$builder = new EnvelopeBuilder('test', '1.0.0', 'ci-host');
80+
$body = $builder->buildEventEnvelope($dsn, 'db boom', 'error', $pdo);
81+
[, , $payload] = $this->decodeEnvelope($body);
82+
83+
self::assertSame('sql', $payload['contexts']['db']['type']);
84+
self::assertSame('42000', $payload['contexts']['db']['sqlstate']);
85+
self::assertSame('1055', $payload['contexts']['db']['code']);
86+
self::assertStringContainsString('select id from t', $payload['contexts']['db']['sql']);
87+
}
88+
7389
public function testRespectsSendOptionsOmissionsAndUserOptIn(): void
7490
{
7591
$dsn = (new BeaconDsnParser())->parse('https://pubkey:secret@localhost:9444/1');

0 commit comments

Comments
 (0)