diff --git a/.github/workflows/audit-errors.yml b/.github/workflows/audit-errors.yml new file mode 100644 index 0000000..1ed1d4a --- /dev/null +++ b/.github/workflows/audit-errors.yml @@ -0,0 +1,35 @@ +name: Audit and client errors +on: + pull_request: + push: + branches: [main, master] +permissions: + contents: read +jobs: + javascript: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: js/package-lock.json + - run: npm ci + working-directory: js + - run: npm run typecheck:errors && npx vitest run && npm run build:errors + working-directory: js + laravel: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - name: Select runner PHP 8.3 + run: | + sudo update-alternatives --set php /usr/bin/php8.3 + php -r 'exit(PHP_VERSION_ID >= 80300 && PHP_VERSION_ID < 80400 && extension_loaded("pdo_sqlite") ? 0 : 1);' + - run: composer install --no-interaction --prefer-dist + working-directory: php/laravel + - run: vendor/bin/phpunit --testsuite=Unit + working-directory: php/laravel diff --git a/js/ERRORS.md b/js/ERRORS.md index 8e816f1..1ba7d59 100644 --- a/js/ERRORS.md +++ b/js/ERRORS.md @@ -86,7 +86,9 @@ a short flush when leaving the foreground. Set the current screen with Promise rejection hooks differ across React Native versions and engines. Pass `subscribeUnhandledRejection(listener)`, returning an unsubscribe function, from your runtime integration when available. `coverage.unhandledRejections` is false -without it. The SDK does not replace Hermes's rejection tracker or claim automatic +without it. Set `requireCoverage: ['globalErrors', 'unhandledRejections']` in +the hooks argument to reject startup configuration that lacks a required hook; +failed installation restores the previous handler and removes subscriptions. The SDK does not replace Hermes's rejection tracker or claim automatic coverage where no compatible subscription is supplied. Handled promise failures can always use `captureException`. @@ -119,8 +121,13 @@ Events exceeding 60 KB or a full queue are dropped. Delivery retries network failures, `408`, `429` and `5xx` up to five attempts, preserving exact bytes and ID. It respects `Retry-After` and uses exponential backoff. Events older than five minutes are discarded; other `4xx` responses are terminal. `getStats()` returns -sent, dropped, retried and queued counts. No delivery failure creates another -telemetry error. Browser visibility changes request a flush with `keepalive`. +sent, dropped, retried and queued counts, plus `droppedByReason`. An optional +`onDiagnostic` callback reports drops and retries without event payloads. Its +exceptions are ignored and error capture inside that callback is suppressed to +avoid recursion. Use a separate health channel to monitor delivery losses. No delivery failure creates another +telemetry error. Browser visibility changes request a flush with `keepalive`. Flush deadlines +apply even if a custom transport ignores abort; late replies are ignored. Closing +the client cancels its pending wait and accounts for queued events once. This is best-effort delivery. Closing a tab, terminating an app, prolonged offline use or fatal JS errors can lose queued events. There is no persistent offline queue. @@ -145,3 +152,23 @@ Upload every generated chunk with its exact URL path. For mobile use actual iOS stack) and the matching composed map. All identity fields must match the event. Maps may be uploaded after an error; refreshing its detail resolves the new map. Group membership remains build-scoped and unchanged. + +## Validate a mobile release + +Hook availability is not proof that a runtime emits every error. Before enabling +this as the only reporter, test on each supported Android/iOS release build: + +1. Trigger a global JS error, a rejected promise, and a React render failure. + Confirm the corresponding mechanism, context and one occurrence in Traceflow. +2. Upload that build's composed map and verify a known source filename and line. + Repeat after an OTA update with a different `dist`. +3. Test offline/reconnect, background/foreground, and a fatal JS error. The fatal + hook starts delivery before invoking the previous handler, but process exit can + still lose the event. Native crashes are not captured. +4. Verify required coverage flags, handler restoration on disposal, and delivery + diagnostics when ingestion returns `429` or `503`. + +The repository tests cover hook contracts, stack parsing, retries and cancellation. +They do not certify a specific React Native/Expo/Hermes version or physical device. +Run `npm run typecheck:errors`, `npm run test:errors`, and `npm run build:errors` +to check the frontend distributions without the optional native Kafka dependency. diff --git a/js/package-lock.json b/js/package-lock.json index 097fea8..747f492 100644 --- a/js/package-lock.json +++ b/js/package-lock.json @@ -21,7 +21,7 @@ "commit-and-tag-version": "^12.7.3", "tsup": "^8.5.1", "typescript": "^5.9.3", - "vitest": "^4.1.7" + "vitest": "^4.1.12" }, "engines": { "node": ">=18.0.0" @@ -71,43 +71,6 @@ "node": ">=6.9.0" } }, - "node_modules/@emnapi/core": { - "version": "2.0.0-beta.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-beta.2.tgz", - "integrity": "sha512-MzCVoWtcg4Pm2B3je5mMvYMvj3MmBKw+fU7vbsJc9/iT8ip6/eYIHo/Dsm/mr+wsHB6SEyJDuxB09Yy6r/8+4A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "2.2.0", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "2.0.0-beta.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-beta.2.tgz", - "integrity": "sha512-JcrSPzPvZuNZvJqPTF9Fw25LbOJ8avzFVlaf2grmH4Cku0oGP47tRgqPZadcfj3YoZMHkFVnCYFbQM8I+i5QEw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.2.0.tgz", - "integrity": "sha512-JP7OYH38Q7+pyTAe14raZHLQIK7BDKbdwji+Xi3pcd/6QkJYQH3SX4wu6EplGrGJWRepoNzedfm0G3B9Z4e9Rw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", @@ -617,28 +580,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.0.tgz", - "integrity": "sha512-kDoONqMa+VnZ4vvvu/ZUurpJ4gkZU57e7g69qpNgWhYcZFPUHZM2CEMKm+cG6ufDVALbjMvfmMjFVqaK7uEMnA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.3" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=23.5.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^2.0.0-alpha.3", - "@emnapi/runtime": "^2.0.0-alpha.3" - } - }, "node_modules/@nodable/entities": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", @@ -653,13 +594,13 @@ "license": "MIT" }, "node_modules/@oxc-project/types": { - "version": "0.139.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", - "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "version": "0.154.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz", + "integrity": "sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==", "dev": true, "license": "MIT", "funding": { - "url": "https://github.com/sponsors/Boshen" + "url": "https://github.com/sponsors/oxc-project" } }, "node_modules/@pkgjs/parseargs": { @@ -673,10 +614,27 @@ "node": ">=14" } }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.14.tgz", + "integrity": "sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", - "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.14.tgz", + "integrity": "sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ==", "cpu": [ "arm64" ], @@ -691,9 +649,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", - "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.14.tgz", + "integrity": "sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw==", "cpu": [ "arm64" ], @@ -708,9 +666,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", - "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.14.tgz", + "integrity": "sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA==", "cpu": [ "x64" ], @@ -725,9 +683,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", - "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.14.tgz", + "integrity": "sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA==", "cpu": [ "x64" ], @@ -742,9 +700,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", - "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.14.tgz", + "integrity": "sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg==", "cpu": [ "arm" ], @@ -759,13 +717,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", - "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.14.tgz", + "integrity": "sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -776,13 +737,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", - "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.14.tgz", + "integrity": "sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -793,13 +757,16 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", - "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.14.tgz", + "integrity": "sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -810,13 +777,16 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", - "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.14.tgz", + "integrity": "sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -827,13 +797,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", - "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.14.tgz", + "integrity": "sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -844,13 +817,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", - "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.14.tgz", + "integrity": "sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -861,9 +837,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", - "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.14.tgz", + "integrity": "sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==", "cpu": [ "arm64" ], @@ -877,63 +853,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", - "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.11.1", - "@emnapi/runtime": "1.11.1", - "@napi-rs/wasm-runtime": "^1.1.6" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", - "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.14.tgz", + "integrity": "sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA==", "cpu": [ "arm64" ], @@ -948,9 +871,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", - "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.14.tgz", + "integrity": "sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ==", "cpu": [ "x64" ], @@ -1328,17 +1251,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", - "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -1406,16 +1318,16 @@ "license": "MIT" }, "node_modules/@vitest/expect": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", - "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.12.tgz", + "integrity": "sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.7", - "@vitest/utils": "4.1.7", + "@vitest/spy": "4.1.12", + "@vitest/utils": "4.1.12", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -1424,13 +1336,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", - "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.12.tgz", + "integrity": "sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.7", + "@vitest/spy": "4.1.12", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1451,9 +1363,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", - "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.12.tgz", + "integrity": "sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==", "dev": true, "license": "MIT", "dependencies": { @@ -1464,13 +1376,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", - "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.12.tgz", + "integrity": "sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.7", + "@vitest/utils": "4.1.12", "pathe": "^2.0.3" }, "funding": { @@ -1478,14 +1390,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", - "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.12.tgz", + "integrity": "sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.7", - "@vitest/utils": "4.1.7", + "@vitest/pretty-format": "4.1.12", + "@vitest/utils": "4.1.12", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1494,9 +1406,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", - "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.12.tgz", + "integrity": "sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==", "dev": true, "license": "MIT", "funding": { @@ -1504,13 +1416,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", - "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.12.tgz", + "integrity": "sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.7", + "@vitest/pretty-format": "4.1.12", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -1612,9 +1524,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1686,9 +1598,9 @@ } }, "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", "dev": true, "license": "MIT", "engines": { @@ -2950,13 +2862,13 @@ "license": "ISC" }, "node_modules/handlebars": { - "version": "4.7.9", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", - "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "version": "4.7.10", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.10.tgz", + "integrity": "sha512-P5VJMVM7qgBn6vjXMw8WG9uVI+ncf2pi72j4de4yz5ZULLj2RGqLYaKOYGsgyrViQ0tePOVlN1tDCCXXtFqXKg==", "dev": true, "license": "MIT", "dependencies": { - "minimist": "^1.2.5", + "minimist": "^1.2.8", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" @@ -3229,9 +3141,9 @@ } }, "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -3245,23 +3157,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", "cpu": [ "arm64" ], @@ -3280,9 +3192,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", "cpu": [ "arm64" ], @@ -3301,9 +3213,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", "cpu": [ "x64" ], @@ -3322,9 +3234,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", "cpu": [ "x64" ], @@ -3343,9 +3255,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", "cpu": [ "arm" ], @@ -3364,13 +3276,16 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3385,13 +3300,16 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3406,13 +3324,16 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3427,13 +3348,16 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3448,9 +3372,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", "cpu": [ "arm64" ], @@ -3469,9 +3393,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", "cpu": [ "x64" ], @@ -3887,9 +3811,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "dev": true, "funding": [ { @@ -4123,9 +4047,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -4168,9 +4092,9 @@ } }, "node_modules/postcss": { - "version": "8.5.25", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", - "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "version": "8.5.30", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", + "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==", "dev": true, "funding": [ { @@ -4188,9 +4112,9 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.16", + "nanoid": "^3.3.20", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" @@ -4473,13 +4397,13 @@ } }, "node_modules/rolldown": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", - "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.14.tgz", + "integrity": "sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.139.0", + "@oxc-project/types": "=0.154.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -4489,21 +4413,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.1.5", - "@rolldown/binding-darwin-arm64": "1.1.5", - "@rolldown/binding-darwin-x64": "1.1.5", - "@rolldown/binding-freebsd-x64": "1.1.5", - "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", - "@rolldown/binding-linux-arm64-gnu": "1.1.5", - "@rolldown/binding-linux-arm64-musl": "1.1.5", - "@rolldown/binding-linux-ppc64-gnu": "1.1.5", - "@rolldown/binding-linux-s390x-gnu": "1.1.5", - "@rolldown/binding-linux-x64-gnu": "1.1.5", - "@rolldown/binding-linux-x64-musl": "1.1.5", - "@rolldown/binding-openharmony-arm64": "1.1.5", - "@rolldown/binding-wasm32-wasi": "1.1.5", - "@rolldown/binding-win32-arm64-msvc": "1.1.5", - "@rolldown/binding-win32-x64-msvc": "1.1.5" + "@rolldown/binding-android-arm-eabi": "1.2.14", + "@rolldown/binding-android-arm64": "1.2.14", + "@rolldown/binding-darwin-arm64": "1.2.14", + "@rolldown/binding-darwin-x64": "1.2.14", + "@rolldown/binding-freebsd-x64": "1.2.14", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.14", + "@rolldown/binding-linux-arm64-gnu": "1.2.14", + "@rolldown/binding-linux-arm64-musl": "1.2.14", + "@rolldown/binding-linux-ppc64-gnu": "1.2.14", + "@rolldown/binding-linux-s390x-gnu": "1.2.14", + "@rolldown/binding-linux-x64-gnu": "1.2.14", + "@rolldown/binding-linux-x64-musl": "1.2.14", + "@rolldown/binding-openharmony-arm64": "1.2.14", + "@rolldown/binding-win32-arm64-msvc": "1.2.14", + "@rolldown/binding-win32-x64-msvc": "1.2.14" } }, "node_modules/rollup": { @@ -4639,9 +4563,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -4992,9 +4916,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz", + "integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==", "dev": true, "license": "MIT", "engines": { @@ -5028,14 +4952,6 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true - }, "node_modules/tsup": { "version": "8.5.1", "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", @@ -5183,16 +5099,16 @@ } }, "node_modules/vite": { - "version": "8.1.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", - "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", "dev": true, "license": "MIT", "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.5", - "postcss": "^8.5.17", - "rolldown": "~1.1.5", + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", "tinyglobby": "^0.2.17" }, "bin": { @@ -5209,7 +5125,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.3.0", + "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -5261,19 +5177,19 @@ } }, "node_modules/vitest": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", - "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.12.tgz", + "integrity": "sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.7", - "@vitest/mocker": "4.1.7", - "@vitest/pretty-format": "4.1.7", - "@vitest/runner": "4.1.7", - "@vitest/snapshot": "4.1.7", - "@vitest/spy": "4.1.7", - "@vitest/utils": "4.1.7", + "@vitest/expect": "4.1.12", + "@vitest/mocker": "4.1.12", + "@vitest/pretty-format": "4.1.12", + "@vitest/runner": "4.1.12", + "@vitest/snapshot": "4.1.12", + "@vitest/spy": "4.1.12", + "@vitest/utils": "4.1.12", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -5301,12 +5217,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.7", - "@vitest/browser-preview": "4.1.7", - "@vitest/browser-webdriverio": "4.1.7", - "@vitest/coverage-istanbul": "4.1.7", - "@vitest/coverage-v8": "4.1.7", - "@vitest/ui": "4.1.7", + "@vitest/browser-playwright": "4.1.12", + "@vitest/browser-preview": "4.1.12", + "@vitest/browser-webdriverio": "^4.1.11", + "@vitest/coverage-istanbul": "4.1.12", + "@vitest/coverage-v8": "4.1.12", + "@vitest/ui": "4.1.12", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/js/package.json b/js/package.json index 2418cdc..f91c1e2 100644 --- a/js/package.json +++ b/js/package.json @@ -18,7 +18,8 @@ "typecheck": "tsc --noEmit", "release": "commit-and-tag-version", "test:errors": "vitest run src/__tests__/errors.test.ts", - "typecheck:errors": "tsc -p tsconfig.errors.json" + "typecheck:errors": "tsc -p tsconfig.errors.json", + "build:errors": "tsup --config tsup.errors.config.ts" }, "repository": { "type": "git", @@ -60,7 +61,7 @@ "commit-and-tag-version": "^12.7.3", "tsup": "^8.5.1", "typescript": "^5.9.3", - "vitest": "^4.1.7" + "vitest": "^4.1.12" }, "engines": { "node": ">=18.0.0" diff --git a/js/src/__tests__/errors.test.ts b/js/src/__tests__/errors.test.ts index d2bc277..a11c292 100644 --- a/js/src/__tests__/errors.test.ts +++ b/js/src/__tests__/errors.test.ts @@ -212,3 +212,126 @@ it("browser listeners capture globals without preventing default behavior and re target.dispatchEvent(event); expect(browser.client.getStats().queued).toBe(0); }); + +it("times out even if a transport ignores abort and ignores a late response", async () => { + let resolve: (response: Response) => void = () => {}; + const { client } = setup( + vi.fn( + () => + new Promise((done) => { + resolve = done; + }), + ), + ); + client.captureException(new Error("stalled")); + expect(await client.flush(10)).toBe(false); + resolve(new Response("{}", { status: 201 })); + await Promise.resolve(); + expect(client.getStats()).toMatchObject({ queued: 1, sent: 0, retries: 1 }); +}); + +it("reports bounded delivery diagnostics without payloads or recursive errors", async () => { + const diagnostics: unknown[] = []; + const client = new ErrorClient({ + ...options, + maxQueueSize: 1, + onDiagnostic: (diagnostic) => { + diagnostics.push(diagnostic); + client.captureException( + new Error("diagnostic callback must not report itself"), + ); + throw new Error("diagnostic failure"); + }, + fetch: vi.fn().mockResolvedValue(new Response("", { status: 400 })), + }); + clients.push(client); + client.captureException(new Error("private application data")); + client.captureException(new Error("overflow")); + await client.flush(); + expect(client.getStats()).toMatchObject({ + dropped: 2, + queued: 0, + droppedByReason: { queue_full: 1, rejected: 1 }, + }); + expect(diagnostics).toHaveLength(2); + expect(JSON.stringify(diagnostics)).not.toContain("application data"); + expect(() => new ErrorClient({ ...options, maxQueueSize: NaN })).toThrow( + "maxQueueSize", + ); +}); + +it("close aborts an in-flight request and accounts for its event exactly once", async () => { + const { client, fetch } = setup(vi.fn(() => new Promise(() => {}))); + client.captureException(new Error("closing")); + const flushing = client.flush(10000); + client.close(); + expect(await flushing).toBe(true); + expect(fetch.mock.calls[0][1].signal.aborted).toBe(true); + expect(client.getStats()).toMatchObject({ + sent: 0, + dropped: 1, + retries: 0, + droppedByReason: { closed: 1 }, + }); +}); + +it("native required coverage fails explicitly and restores installed handlers", () => { + const previous = vi.fn(); + let handler: GlobalErrorHandler = previous; + const remove = vi.fn(); + const hooks = { + errorUtils: { + getGlobalHandler: () => handler, + setGlobalHandler: (next: GlobalErrorHandler) => { + handler = next; + }, + }, + appState: { addEventListener: () => ({ remove }) }, + requireCoverage: ["globalErrors", "unhandledRejections"] as ( + "globalErrors" | "unhandledRejections" + )[], + }; + expect(() => installReactNativeErrors(options, hooks)).toThrow( + "unhandledRejections", + ); + expect(handler).toBe(previous); + expect(remove).toHaveBeenCalledOnce(); + expect(() => + installReactNativeErrors(options, { + ...hooks, + subscribeUnhandledRejection: () => { + throw new Error("runtime hook failed"); + }, + }), + ).toThrow("runtime hook failed"); + expect(handler).toBe(previous); +}); + +it("native fatal errors start delivery before invoking the existing crash handler", async () => { + const order: string[] = []; + let handler: GlobalErrorHandler = () => { + order.push("crash"); + }; + const reporting = installReactNativeErrors( + { + ...options, + fetch: vi.fn(async () => { + order.push("send"); + return new Response("{}", { status: 201 }); + }), + }, + { + errorUtils: { + getGlobalHandler: () => handler, + setGlobalHandler: (next) => { + handler = next; + }, + }, + }, + ); + clients.push(reporting.client); + handler(new Error("fatal"), true); + expect(order).toEqual(["send", "crash"]); + await reporting.client.flush(); + reporting.dispose(); +}); diff --git a/js/src/errors/core.ts b/js/src/errors/core.ts index 64d5a20..b3d452c 100644 --- a/js/src/errors/core.ts +++ b/js/src/errors/core.ts @@ -34,6 +34,22 @@ export interface ClientErrorEvent { context: Record; breadcrumbs: Breadcrumb[]; } +export type DropReason = + | "sampled" + | "filtered" + | "invalid" + | "oversize" + | "queue_full" + | "expired" + | "rejected" + | "retry_exhausted" + | "closed"; +export interface ErrorDiagnostic { + type: "dropped" | "retry"; + reason?: DropReason; + status?: number; + queued: number; +} export interface ErrorClientOptions { endpoint: string; ingestKey: string; @@ -47,6 +63,8 @@ export interface ErrorClientOptions { fetch?: typeof fetch; maxQueueSize?: number; sampleRate?: number; + /** Contains delivery metadata only. Exceptions in this hook are ignored. */ + onDiagnostic?: (diagnostic: ErrorDiagnostic) => void; } export interface CaptureOptions { mechanism?: ClientErrorEvent["mechanism"]; @@ -169,6 +187,9 @@ export class ErrorClient { private sending?: Promise; private closed = false; private capturing = false; + private reporting = false; + private abort?: AbortController; + private readonly droppedByReason: Partial> = {}; private readonly send: typeof fetch; private readonly maxQueue: number; private readonly stats = { sent: 0, dropped: 0, retries: 0 }; @@ -199,6 +220,13 @@ export class ErrorClient { options.sampleRate > 1) ) throw new Error("sampleRate must be between 0 and 1"); + if ( + options.maxQueueSize !== undefined && + (!Number.isInteger(options.maxQueueSize) || + options.maxQueueSize < 1 || + options.maxQueueSize > 100) + ) + throw new Error("maxQueueSize must be an integer between 1 and 100"); this.send = options.fetch ?? globalThis.fetch.bind(globalThis); this.maxQueue = Math.max(1, Math.min(100, options.maxQueueSize ?? 30)); this.context = { session_id: uuid(), ...options.context }; @@ -233,7 +261,7 @@ export class ErrorClient { error: unknown, capture: CaptureOptions = {}, ): string | undefined { - if (this.closed || this.capturing) return; + if (this.closed || this.capturing || this.reporting) return; this.capturing = true; try { const now = Date.now(); @@ -242,7 +270,7 @@ export class ErrorClient { if (previous && now - previous.at < 1000) return previous.id; } if (Math.random() >= (this.options.sampleRate ?? 1)) { - this.stats.dropped++; + this.drop("sampled"); return; } const object = @@ -293,7 +321,7 @@ export class ErrorClient { }; if (this.options.beforeSend) event = this.options.beforeSend(event); if (!event) { - this.stats.dropped++; + this.drop("filtered"); return; } const body = JSON.stringify(event); @@ -303,7 +331,7 @@ export class ErrorClient { "_", ).length; if (bytes > 60000 || this.queue.length >= this.maxQueue) { - this.stats.dropped++; + this.drop(bytes > 60000 ? "oversize" : "queue_full"); return; } this.queue.push({ body, attempts: 0, next: now, created: now }); @@ -312,7 +340,7 @@ export class ErrorClient { this.schedule(); return event.event_id; } catch { - this.stats.dropped++; + this.drop("invalid"); return; } finally { this.capturing = false; @@ -337,12 +365,17 @@ export class ErrorClient { } while (!this.closed && this.queue.length && Date.now() < deadline) { if (this.sending) { - await Promise.race([ - this.sending, - new Promise((resolve) => - setTimeout(resolve, Math.max(0, deadline - Date.now())), - ), - ]); + let timer: ReturnType | undefined; + try { + await Promise.race([ + this.sending, + new Promise((resolve) => { + timer = setTimeout(resolve, Math.max(0, deadline - Date.now())); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } continue; } const pending = this.queue[0]; @@ -363,25 +396,40 @@ export class ErrorClient { private async deliver(pending: Pending, timeoutMs: number): Promise { if (Date.now() - pending.created > 300000) { this.queue.shift(); - this.stats.dropped++; + this.drop("expired"); return; } const abort = new AbortController(); - const timer = setTimeout(() => abort.abort(), timeoutMs); - let retryAfter = 0; + this.abort = abort; + let timer: ReturnType | undefined; + const cancelled = new Promise((_resolve, reject) => { + abort.signal.addEventListener( + "abort", + () => reject(new Error("Delivery cancelled")), + { once: true }, + ); + timer = setTimeout(() => abort.abort(), timeoutMs); + }); + let retryAfter = 0, + status: number | undefined; try { - const response = await this.send(this.options.endpoint, { - method: "POST", - body: pending.body, - headers: { - "Content-Type": "application/json", - "X-Traceflow-Ingest-Key": this.options.ingestKey, - }, - signal: abort.signal, - ...(this.options.platform === "browser" - ? { keepalive: true, credentials: "omit" as const } - : {}), - }); + const response = await Promise.race([ + cancelled, + this.send(this.options.endpoint, { + method: "POST", + body: pending.body, + headers: { + "Content-Type": "application/json", + "X-Traceflow-Ingest-Key": this.options.ingestKey, + }, + signal: abort.signal, + ...(this.options.platform === "browser" + ? { keepalive: true, credentials: "omit" as const } + : {}), + }), + ]); + if (this.closed) return; + status = response.status; if (response.ok) { this.queue.shift(); this.stats.sent++; @@ -393,7 +441,7 @@ export class ErrorClient { response.status !== 408 ) { this.queue.shift(); - this.stats.dropped++; + this.drop("rejected", response.status); return; } const header = response.headers.get("Retry-After"); @@ -404,12 +452,14 @@ export class ErrorClient { } catch { /* The transport cannot recursively capture its own failures. */ } finally { - clearTimeout(timer); + if (timer) clearTimeout(timer); + this.abort = undefined; } + if (this.closed) return; pending.attempts++; if (pending.attempts >= 5) { this.queue.shift(); - this.stats.dropped++; + this.drop("retry_exhausted", status); return; } pending.next = @@ -419,14 +469,37 @@ export class ErrorClient { Math.min(30000, 1000 * 2 ** (pending.attempts - 1)), ); this.stats.retries++; + this.notify({ type: "retry", status, queued: this.queue.length }); + } + private notify(diagnostic: ErrorDiagnostic) { + if (this.reporting) return; + this.reporting = true; + try { + this.options.onDiagnostic?.(diagnostic); + } catch { + /* Never report reporting failures. */ + } finally { + this.reporting = false; + } + } + private drop(reason: DropReason, status?: number, count = 1) { + this.stats.dropped += count; + this.droppedByReason[reason] = (this.droppedByReason[reason] ?? 0) + count; + this.notify({ type: "dropped", reason, status, queued: this.queue.length }); } getStats() { - return { ...this.stats, queued: this.queue.length }; + return { + ...this.stats, + queued: this.queue.length, + droppedByReason: { ...this.droppedByReason }, + }; } close(): void { this.closed = true; if (this.timer) clearTimeout(this.timer); - this.stats.dropped += this.queue.length; + const count = this.queue.length; this.queue = []; + if (count) this.drop("closed", undefined, count); + this.abort?.abort(); } } diff --git a/js/src/react-native.ts b/js/src/react-native.ts index 90d21c8..b6984cf 100644 --- a/js/src/react-native.ts +++ b/js/src/react-native.ts @@ -3,6 +3,8 @@ import type { ErrorClientOptions } from "./errors/core"; export * from "./errors/core"; export type GlobalErrorHandler = (error: unknown, fatal?: boolean) => void; export interface NativeErrorHooks { + /** Fail installation if these hooks are missing; previously installed handlers are restored. */ + requireCoverage?: ("globalErrors" | "unhandledRejections")[]; errorUtils?: { getGlobalHandler(): GlobalErrorHandler; setGlobalHandler(handler: GlobalErrorHandler): void; @@ -37,32 +39,58 @@ export function installReactNativeErrors( handled: false, context: { fatal: Boolean(fatal) }, }); - // Preserve React Native's crash/redbox behavior and any existing reporting hook. + // Start delivery before a fatal handler can terminate the JS runtime. + // This remains best effort; there is no native crash spool. + if (fatal) void client.flush(1000); previous?.(error, fatal); }; - errorUtils?.setGlobalHandler(handler); - const subscription = hooks.appState?.addEventListener("change", (state) => { - client.addBreadcrumb({ category: "app", message: state }); - if (state !== "active") void client.flush(1000); - }); - const unsubscribe = hooks.subscribeUnhandledRejection?.((reason) => { - client.captureException(reason, { - mechanism: "unhandledrejection", - handled: false, - }); - }); - return { - client, - coverage: { - globalErrors: Boolean(errorUtils), - unhandledRejections: Boolean(unsubscribe), - }, - dispose() { + let subscription: { remove(): void } | undefined; + let unsubscribe: (() => void) | undefined; + const dispose = () => { + try { if (errorUtils?.getGlobalHandler() === handler && previous) errorUtils.setGlobalHandler(previous); - subscription?.remove(); - unsubscribe?.(); - client.close(); - }, + } finally { + try { + subscription?.remove(); + } finally { + try { + unsubscribe?.(); + } finally { + client.close(); + } + } + } }; + try { + errorUtils?.setGlobalHandler(handler); + subscription = hooks.appState?.addEventListener("change", (state) => { + client.addBreadcrumb({ category: "app", message: state }); + if (state !== "active") void client.flush(1000); + }); + unsubscribe = hooks.subscribeUnhandledRejection?.((reason) => { + client.captureException(reason, { + mechanism: "unhandledrejection", + handled: false, + }); + }); + const coverage = { + globalErrors: Boolean(errorUtils), + unhandledRejections: Boolean(unsubscribe), + }; + const missing = + hooks.requireCoverage?.filter((key) => !coverage[key]) ?? []; + if (missing.length) + throw new Error( + "Missing React Native error hooks: " + missing.join(", "), + ); + return { client, coverage, dispose }; + } catch (error) { + try { + dispose(); + } catch { + /* Preserve the installation error. */ + } + throw error; + } } diff --git a/js/tsup.errors.config.ts b/js/tsup.errors.config.ts new file mode 100644 index 0000000..869eefe --- /dev/null +++ b/js/tsup.errors.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "tsup"; + +// Validate frontend distributions without installing the optional native Kafka peer. +export default defineConfig({ + entry: [ + "src/browser.ts", + "src/react-native.ts", + "src/nuxt.ts", + "src/react.ts", + ], + format: ["cjs", "esm"], + dts: true, + clean: false, +}); diff --git a/php/laravel/README.md b/php/laravel/README.md index 81223fa..994fee0 100644 --- a/php/laravel/README.md +++ b/php/laravel/README.md @@ -97,23 +97,45 @@ Override `customizeAudit(array $event): array` to redact or enrich an event. Run this command periodically from the application's scheduler or job runner: ```sh -php artisan traceflow:audit-publish --limit=100 +php artisan traceflow:audit-publish --limit=100 --max-seconds=30 --json ``` For a named database, add `--connection=business`. Each invocation processes at most the requested number of ready rows and exits. Choose the frequency and -batch size for the expected volume. +batch size for the expected volume. `--max-seconds` bounds delivery attempts and +HTTP timeouts for one invocation (default 30, maximum 300); database queries still +use the application's database timeout. A transient delivery failure stops the +batch to avoid retrying every row during an outage. Remaining rows stay pending. +`--json` reports published, retry-scheduled and failed counts. A delivery failure +or exhausted time budget returns a nonzero exit code. A row is deleted only after Traceflow confirms persistence. Transient failures -retain the same event and set a later `available_at`. Permanent failures set -`failed_at` and retain the row for review. After fixing the cause, clear -`failed_at` and reset `available_at` to retry. Investigate `409` conflicts before -resending: the same event ID cannot describe different data. +retain the same event and set a later `available_at`. Missing credentials and +HTTP `401`/`403`/`404` also retain pending rows: fixing publisher configuration or +deploying the route must not require reconstructing audit events. Those HTTP +responses end the current delivery attempt without immediate HTTP retries. Permanent failures set +`failed_at` and retain the row for review. After fixing the cause, run +`php artisan traceflow:audit-retry EVENT_ID`. It preserves the original payload, +ID and attempt history. Investigate `409` conflicts before resending: the same +event ID cannot describe different data. Concurrent publishers may send duplicates; Traceflow deduplicates by source and event ID. Audit history is read from Traceflow. The outbox holds pending or failed deliveries, not the history shown to users. +Monitor the publisher separately from application request health: + +```sh +php artisan traceflow:audit-status --max-age=300 --max-pending=10000 +``` + +The command emits JSON with pending/ready/failed counts and oldest pending age. +It exits nonzero when there are failed rows or the age/backlog thresholds are +exceeded. Feed this result and publisher exit codes into your scheduler's alerting. +Use `--connection` consistently for capture, publishing, status and retries. +Publish and apply the additional `index_traceflow_audit_outbox` migration when +upgrading an existing installation; it indexes ready rows and pending age. + ### Manual capture Bulk updates/deletes, raw SQL, pivot changes and muted Eloquent events bypass diff --git a/php/laravel/database/migrations/index_traceflow_audit_outbox.php.stub b/php/laravel/database/migrations/index_traceflow_audit_outbox.php.stub new file mode 100644 index 0000000..10f9ab9 --- /dev/null +++ b/php/laravel/database/migrations/index_traceflow_audit_outbox.php.stub @@ -0,0 +1,24 @@ +index(['failed_at', 'available_at'], 'audit_outbox_ready'); + $table->index(['failed_at', 'created_at'], 'audit_outbox_age'); + }); + } + + public function down(): void + { + Schema::table('traceflow_audit_outbox', function (Blueprint $table) { + $table->dropIndex('audit_outbox_ready'); + $table->dropIndex('audit_outbox_age'); + }); + } +}; diff --git a/php/laravel/src/Audit/AuditClient.php b/php/laravel/src/Audit/AuditClient.php index c833433..a82a612 100644 --- a/php/laravel/src/Audit/AuditClient.php +++ b/php/laravel/src/Audit/AuditClient.php @@ -21,23 +21,33 @@ public function recordAudit(array $input): array } /** Synchronous durable receipt; never uses the tracing silent_errors or async transport. */ - public function record(AuditEvent $event): array + public function record(AuditEvent $event, ?float $budgetSeconds = null): array { if (($this->config['enabled'] ?? true) === false) { throw new AuditDeliveryException('Audit delivery is disabled', $event->id(), true); } - if (empty($this->config['endpoint']) || empty($this->config['api_key']) || $event->source() !== ($this->config['source'] ?? null)) { - throw new AuditDeliveryException('Audit requires endpoint, API key and matching source', $event->id(), false); + if (empty($this->config['endpoint']) || empty($this->config['api_key'])) { + throw new AuditDeliveryException('Audit requires endpoint and API key', $event->id(), true); } + if ($event->source() !== ($this->config['source'] ?? null)) { + throw new AuditDeliveryException('Audit event source does not match publisher source', $event->id(), false); + } + $deadline = $budgetSeconds === null ? null : hrtime(true) / 1e9 + max(0, $budgetSeconds); $http = $this->http ?? new Client; $retries = min(10, max(0, (int) ($this->config['max_retries'] ?? 3))); for ($attempt = 0; ; $attempt++) { + $remaining = $deadline === null ? INF : $deadline - hrtime(true) / 1e9; + if ($remaining <= 0) { + throw new AuditDeliveryException('Audit delivery deadline exceeded', $event->id(), true); + } $delay = min(30000, ($this->config['retry_delay'] ?? 250) * (2 ** $attempt)); try { $response = $http->request('POST', rtrim($this->config['endpoint'], '/').'/api/v1/audit-events', [ 'headers' => ['X-API-Key' => $this->config['api_key'], 'Content-Type' => 'application/json'], 'body' => $event->toJson(), - 'timeout' => $this->config['timeout'] ?? 10, + 'timeout' => min(max(0.001, (float) ($this->config['timeout'] ?? 10)), $remaining), + 'connect_timeout' => min(5.0, $remaining), + 'allow_redirects' => false, 'http_errors' => false, ]); $status = $response->getStatusCode(); @@ -54,13 +64,18 @@ public function record(AuditEvent $event): array $seconds = is_numeric($retryAfter) ? (float) $retryAfter : max(0, (strtotime($retryAfter) ?: time()) - time()); $delay = max($delay, min(30000, $seconds * 1000)); } - throw new AuditDeliveryException("Audit request failed ({$status})", $event->id(), $status >= 500 || in_array($status, [408, 429], true), $status); + throw new AuditDeliveryException("Audit request failed ({$status})", $event->id(), $status >= 500 || in_array($status, [401, 403, 404, 408, 429], true), $status); } catch (GuzzleException $exception) { $failure = new AuditDeliveryException('Audit request failed or timed out', $event->id(), true); } catch (AuditDeliveryException $exception) { $failure = $exception; } - if (! $failure->retryable || $attempt >= $retries) { + // Authentication/routing can recover after configuration or deployment changes. + // Retain the outbox row, but do not repeat identical requests in this invocation. + if (! $failure->retryable || $attempt >= $retries || in_array($failure->statusCode, [401, 403, 404], true)) { + throw $failure; + } + if ($deadline !== null && hrtime(true) / 1e9 + $delay / 1000 >= $deadline) { throw $failure; } usleep((int) ($delay * 1000)); diff --git a/php/laravel/src/Console/AuditStatusCommand.php b/php/laravel/src/Console/AuditStatusCommand.php new file mode 100644 index 0000000..8fa7f10 --- /dev/null +++ b/php/laravel/src/Console/AuditStatusCommand.php @@ -0,0 +1,34 @@ +option('connection') ?: null)->table('traceflow_audit_outbox'); + $pending = (clone $table)->whereNull('failed_at'); + $oldest = (clone $pending)->min('created_at'); + $age = $oldest === null ? 0 : max(0, now()->getTimestamp() - Carbon::parse($oldest)->getTimestamp()); + $result = [ + 'pending' => (clone $pending)->count(), + 'ready' => (clone $pending)->where('available_at', '<=', now())->count(), + 'failed' => (clone $table)->whereNotNull('failed_at')->count(), + 'oldest_pending_seconds' => $age, + ]; + $result['healthy'] = $result['failed'] === 0 + && $age <= max(0, (int) $this->option('max-age')) + && $result['pending'] <= max(0, (int) $this->option('max-pending')); + $this->line(json_encode($result, JSON_THROW_ON_ERROR)); + + return $result['healthy'] ? self::SUCCESS : self::FAILURE; + } +} diff --git a/php/laravel/src/Console/PublishAuditCommand.php b/php/laravel/src/Console/PublishAuditCommand.php index 43508f2..eac2b74 100644 --- a/php/laravel/src/Console/PublishAuditCommand.php +++ b/php/laravel/src/Console/PublishAuditCommand.php @@ -10,21 +10,29 @@ final class PublishAuditCommand extends Command { - protected $signature = 'traceflow:audit-publish {--connection=} {--limit=100}'; + protected $signature = 'traceflow:audit-publish {--connection=} {--limit=100} {--max-seconds=30} {--json}'; protected $description = 'Deliver committed audit envelopes from the transactional outbox'; public function handle(AuditClient $client): int { + $deadline = hrtime(true) / 1e9 + min(300, max(1, (int) $this->option('max-seconds'))); + $stats = ['published' => 0, 'retry_scheduled' => 0, 'failed' => 0, 'budget_exhausted' => false]; $db = DB::connection($this->option('connection') ?: null); $rows = $db->table('traceflow_audit_outbox')->whereNull('failed_at')->where('available_at', '<=', now()) ->orderBy('created_at')->limit(min(1000, max(1, (int) $this->option('limit'))))->get(); $failed = false; foreach ($rows as $row) { + $remaining = $deadline - hrtime(true) / 1e9; + if ($remaining <= 0) { + $stats['budget_exhausted'] = true; + break; + } try { - $client->record(AuditEvent::fromJson($row->payload)); + $client->record(AuditEvent::fromJson($row->payload), $remaining); // Concurrent publishers are safe: identical event IDs are idempotent in Traceflow. $db->table('traceflow_audit_outbox')->where('event_id', $row->event_id)->delete(); + $stats['published']++; } catch (\Throwable $error) { $failed = true; $permanent = $error instanceof AuditDeliveryException && ! $error->retryable || $error instanceof \InvalidArgumentException || $error instanceof \JsonException; @@ -34,10 +42,21 @@ public function handle(AuditClient $client): int 'failed_at' => $permanent ? now() : null, 'last_error' => $error instanceof AuditDeliveryException ? $error->getMessage() : 'Audit publishing failed; inspect the envelope and publisher configuration', ]); - $this->error("Audit {$row->event_id}: ".($permanent ? 'requires attention' : 'scheduled for retry')); + $stats[$permanent ? 'failed' : 'retry_scheduled']++; + if (! $this->option('json')) { + $this->error("Audit {$row->event_id}: ".($permanent ? 'requires attention' : 'scheduled for retry')); + } + // A service outage must not trigger retries for every row in the batch. + if (! $permanent) { + break; + } } } - return $failed ? self::FAILURE : self::SUCCESS; + if ($this->option('json')) { + $this->line(json_encode($stats, JSON_THROW_ON_ERROR)); + } + + return $failed || $stats['budget_exhausted'] ? self::FAILURE : self::SUCCESS; } } diff --git a/php/laravel/src/Console/RetryAuditCommand.php b/php/laravel/src/Console/RetryAuditCommand.php new file mode 100644 index 0000000..8029404 --- /dev/null +++ b/php/laravel/src/Console/RetryAuditCommand.php @@ -0,0 +1,23 @@ +option('connection') ?: null)->table('traceflow_audit_outbox') + ->where('event_id', $this->argument('event_id'))->whereNotNull('failed_at') + ->update(['failed_at' => null, 'available_at' => now(), 'last_error' => null]); + $this->line($changed ? 'Audit event requeued with its original ID and payload.' : 'Failed audit event not found.'); + + return $changed ? self::SUCCESS : self::FAILURE; + } +} diff --git a/php/laravel/src/TraceFlowServiceProvider.php b/php/laravel/src/TraceFlowServiceProvider.php index ba801f7..669a9ab 100644 --- a/php/laravel/src/TraceFlowServiceProvider.php +++ b/php/laravel/src/TraceFlowServiceProvider.php @@ -3,11 +3,13 @@ namespace Smartness\TraceFlow; use Illuminate\Support\ServiceProvider; -use Smartness\TraceFlow\Console\BattleTestCommand; -use Smartness\TraceFlow\Console\TestCommand; -use Smartness\TraceFlow\Console\PublishAuditCommand; use Smartness\TraceFlow\Audit\AuditClient; use Smartness\TraceFlow\Audit\AuditContext; +use Smartness\TraceFlow\Console\AuditStatusCommand; +use Smartness\TraceFlow\Console\BattleTestCommand; +use Smartness\TraceFlow\Console\PublishAuditCommand; +use Smartness\TraceFlow\Console\RetryAuditCommand; +use Smartness\TraceFlow\Console\TestCommand; class TraceFlowServiceProvider extends ServiceProvider { @@ -19,7 +21,7 @@ public function register(): void $this->mergeConfigFrom( __DIR__.'/../config/traceflow.php', 'traceflow' ); - $this->app->scoped(AuditContext::class, fn () => new AuditContext()); + $this->app->scoped(AuditContext::class, fn () => new AuditContext); $this->app->singleton(AuditClient::class, fn () => new AuditClient(config('traceflow'))); // Register SDK as singleton @@ -58,9 +60,10 @@ public function boot(): void ], 'traceflow-config'); // Register commands - $this->commands([TestCommand::class, BattleTestCommand::class, PublishAuditCommand::class]); + $this->commands([TestCommand::class, BattleTestCommand::class, PublishAuditCommand::class, AuditStatusCommand::class, RetryAuditCommand::class]); $this->publishes([ __DIR__.'/../database/migrations/create_traceflow_audit_outbox.php.stub' => database_path('migrations/2026_10_16_000000_create_traceflow_audit_outbox.php'), + __DIR__.'/../database/migrations/index_traceflow_audit_outbox.php.stub' => database_path('migrations/2026_10_17_000000_index_traceflow_audit_outbox.php'), ], 'traceflow-audit-migrations'); } diff --git a/php/laravel/tests/Unit/AuditOutboxTest.php b/php/laravel/tests/Unit/AuditOutboxTest.php index 98fb78c..9a7a03d 100644 --- a/php/laravel/tests/Unit/AuditOutboxTest.php +++ b/php/laravel/tests/Unit/AuditOutboxTest.php @@ -36,6 +36,7 @@ protected function setUp(): void { parent::setUp(); (require __DIR__.'/../../database/migrations/create_traceflow_audit_outbox.php.stub')->up(); + (require __DIR__.'/../../database/migrations/index_traceflow_audit_outbox.php.stub')->up(); Schema::create('audit_test_subjects', function (Blueprint $table) { $table->id(); $table->string('status')->nullable(); @@ -106,6 +107,54 @@ public function test_publisher_keeps_failed_delivery_then_deletes_only_after_per $this->artisan('traceflow:audit-publish')->assertExitCode(0); $this->assertSame(0, DB::table('traceflow_audit_outbox')->count()); } + + public function test_outage_stops_the_batch_without_discarding_other_events(): void + { + DB::transaction(function () { + for ($i = 0; $i < 3; $i++) { + AuditTestSubject::create(['status' => 'draft']); + } + }); + $mock = new MockHandler([new Response(503)]); + $this->app->instance(AuditClient::class, new AuditClient(['source' => 'audit-test', 'endpoint' => 'http://audit.test', 'api_key' => 'key', 'max_retries' => 0], new Client(['handler' => HandlerStack::create($mock)]))); + $this->artisan('traceflow:audit-publish', ['--json' => true])->assertExitCode(1); + $this->assertSame(3, DB::table('traceflow_audit_outbox')->count()); + $this->assertSame(1, (int) DB::table('traceflow_audit_outbox')->sum('attempts')); + $this->assertSame(0, DB::table('traceflow_audit_outbox')->whereNotNull('failed_at')->count()); + } + + public function test_status_detects_backlog_and_failed_events_can_be_requeued_without_mutation(): void + { + $this->artisan('traceflow:audit-status')->assertExitCode(0); + DB::transaction(fn () => AuditTestSubject::create(['status' => 'draft'])); + $row = DB::table('traceflow_audit_outbox')->first(); + DB::table('traceflow_audit_outbox')->update(['created_at' => now()->subMinutes(10)]); + $this->artisan('traceflow:audit-status', ['--max-age' => 300])->assertExitCode(1); + $this->artisan('traceflow:audit-status', ['--max-age' => 1000])->assertExitCode(0); + $this->artisan('traceflow:audit-status', ['--max-age' => 1000, '--max-pending' => 0])->assertExitCode(1); + DB::table('traceflow_audit_outbox')->update(['failed_at' => now(), 'attempts' => 2]); + $this->artisan('traceflow:audit-status')->assertExitCode(1); + $this->artisan('traceflow:audit-retry', ['event_id' => $row->event_id])->assertExitCode(0); + $retry = DB::table('traceflow_audit_outbox')->first(); + $this->assertNull($retry->failed_at); + $this->assertSame($row->payload, $retry->payload); + $this->assertSame(2, $retry->attempts); + $this->artisan('traceflow:audit-retry', ['event_id' => $row->event_id])->assertExitCode(1); + } + + public function test_permanent_failure_does_not_block_valid_events_and_can_be_recovered(): void + { + DB::transaction(function () { + AuditTestSubject::create(['status' => 'invalid']); + AuditTestSubject::create(['status' => 'valid']); + }); + $rows = DB::table('traceflow_audit_outbox')->orderBy('created_at')->get(); + $mock = new MockHandler([new Response(400), new Response(201, [], json_encode(['status' => 'persisted', 'event_id' => $rows[1]->event_id]))]); + $this->app->instance(AuditClient::class, new AuditClient(['source' => 'audit-test', 'endpoint' => 'http://audit.test', 'api_key' => 'key', 'max_retries' => 0], new Client(['handler' => HandlerStack::create($mock)]))); + $this->artisan('traceflow:audit-publish')->assertExitCode(1); + $this->assertSame(1, DB::table('traceflow_audit_outbox')->count()); + $this->assertNotNull(DB::table('traceflow_audit_outbox')->value('failed_at')); + } } class AuditTestSubject extends Model diff --git a/php/laravel/tests/Unit/AuditTest.php b/php/laravel/tests/Unit/AuditTest.php index 2b78722..4548b79 100644 --- a/php/laravel/tests/Unit/AuditTest.php +++ b/php/laravel/tests/Unit/AuditTest.php @@ -63,4 +63,40 @@ public function test_context_is_restored_even_after_an_exception(): void } $this->assertSame([], $context->values()); } + + public function test_publisher_deadline_caps_http_timeout_and_does_not_sleep_past_budget(): void + { + $event = AuditEvent::create('test', ['action' => 'viewed']); + $history = []; + $handler = HandlerStack::create(new MockHandler([new Response(503)])); + $handler->push(Middleware::history($history)); + $client = new AuditClient(['source' => 'test', 'endpoint' => 'http://audit.test', 'api_key' => 'key', 'retry_delay' => 30000], new Client(['handler' => $handler])); + $start = microtime(true); + try { + $client->record($event, 0.1); + $this->fail('Expected retryable delivery failure'); + } catch (AuditDeliveryException $error) { + $this->assertTrue($error->retryable); + } + $this->assertLessThan(1, microtime(true) - $start); + $this->assertCount(1, $history); + $this->assertLessThanOrEqual(0.1, $history[0]['options']['timeout']); + $this->assertFalse($history[0]['options']['allow_redirects']); + } + + public function test_authentication_and_missing_route_failures_remain_recoverable_without_immediate_retries(): void + { + foreach ([401, 403, 404] as $status) { + $mock = new MockHandler([new Response($status)]); + $client = new AuditClient(['source' => 'test', 'endpoint' => 'http://audit.test', 'api_key' => 'key'], new Client(['handler' => HandlerStack::create($mock)])); + try { + $client->recordAudit(['action' => 'viewed']); + $this->fail('Expected delivery failure'); + } catch (AuditDeliveryException $error) { + $this->assertTrue($error->retryable); + $this->assertSame($status, $error->statusCode); + $this->assertSame(0, $mock->count()); + } + } + } } diff --git a/php/laravel/tests/Unit/TraceFlowSDKTest.php b/php/laravel/tests/Unit/TraceFlowSDKTest.php index e223234..9593f78 100644 --- a/php/laravel/tests/Unit/TraceFlowSDKTest.php +++ b/php/laravel/tests/Unit/TraceFlowSDKTest.php @@ -131,7 +131,8 @@ public function test_throws_exception_for_kafka_transport(): void public function test_start_trace_returns_trace_handle(): void { $sdk = new TraceFlowSDK([ - 'transport' => 'http', + // These tests exercise handles/context; transport delivery has its own tests. + 'enabled' => false, 'source' => 'test', 'endpoint' => 'http://localhost:3009', 'silent_errors' => true, @@ -149,7 +150,8 @@ public function test_start_trace_returns_trace_handle(): void public function test_get_current_trace_returns_active_trace(): void { $sdk = new TraceFlowSDK([ - 'transport' => 'http', + // These tests exercise handles/context; transport delivery has its own tests. + 'enabled' => false, 'source' => 'test', 'endpoint' => 'http://localhost:3009', 'silent_errors' => true, @@ -170,7 +172,8 @@ public function test_get_current_trace_returns_active_trace(): void public function test_run_with_trace_executes_callback(): void { $sdk = new TraceFlowSDK([ - 'transport' => 'http', + // These tests exercise handles/context; transport delivery has its own tests. + 'enabled' => false, 'source' => 'test', 'endpoint' => 'http://localhost:3009', 'silent_errors' => true, @@ -194,7 +197,8 @@ public function test_run_with_trace_executes_callback(): void public function test_run_with_trace_handles_exception(): void { $sdk = new TraceFlowSDK([ - 'transport' => 'http', + // These tests exercise handles/context; transport delivery has its own tests. + 'enabled' => false, 'source' => 'test', 'endpoint' => 'http://localhost:3009', 'silent_errors' => true, @@ -240,7 +244,8 @@ public function test_start_step_without_current_trace_returns_null(): void public function test_start_step_with_current_trace_returns_step_handle(): void { $sdk = new TraceFlowSDK([ - 'transport' => 'http', + // These tests exercise handles/context; transport delivery has its own tests. + 'enabled' => false, 'source' => 'test', 'endpoint' => 'http://localhost:3009', 'silent_errors' => true,