Skip to content

(feat): richer Sentry exceptions in span — chained causes, mechanism, typed constants#88

Merged
ChiragAgg5k merged 2 commits into
mainfrom
feat/span-rich-sentry-exceptions
Jul 24, 2026
Merged

(feat): richer Sentry exceptions in span — chained causes, mechanism, typed constants#88
ChiragAgg5k merged 2 commits into
mainfrom
feat/span-rich-sentry-exceptions

Conversation

@ChiragAgg5k

@ChiragAgg5k ChiragAgg5k commented Jul 24, 2026

Copy link
Copy Markdown
Member

What

Makes span's Sentry exporter emit the exception structure Sentry's UI is built around — matching what mature SDKs (e.g. sentry-python) send — so events show the full cause chain, handled/unhandled chips, and named frames instead of a single flat exception. Also types every class constant and fixes the package's PHP floor.

Why

Comparing a span-reported event against a sentry-python one, span events were missing:

  • the exception cause chaingetPrevious() was ignored, so the root cause (e.g. the underlying S3/curl error behind a storage failure) was silently dropped
  • mechanism metadata — no handled: true/false chip, no cause-tree linking
  • a function name on the throw-site frame — the top frame rendered anonymous

Changes

1. Chained exceptions (Sentry exception interface)

Before — only the outermost exception, previous causes dropped:

'exception' => [
    'values' => [[
        'type' => $error::class,
        'value' => $error->getMessage(),
        'stacktrace' => ['frames' => $frames],
    ]],
],

After — the full getPrevious() chain (capped at 10), root cause first, each value linked into a mechanism tree:

'exception' => [
    'values' => $this->buildExceptionValues($error, $level),
],
$chain = [];
for ($current = $error; $current instanceof \Throwable && \count($chain) < self::MAX_CHAIN_DEPTH; $current = $current->getPrevious()) {
    $chain[] = $current;
}

foreach ($chain as $id => $exception) {
    $mechanism = ['type' => 'generic', 'handled' => $handled];

    if ($chained) {
        $mechanism['exception_id'] = $id;
        if ($id > 0) {
            $mechanism['parent_id'] = $id - 1;
            $mechanism['source'] = '__previous__';
        }
    }
    // ... type, value, stacktrace, module per value
}

return array_reverse($values); // oldest cause first, per the Sentry spec

handled derives from the exported level: warning/error spans were caught and reported by app code (handled: true); fatal spans came from a process-level handler (handled: false).

2. Throw-site frame gets its enclosing function

PHP traces describe call sites, so the exporter appends a synthetic frame for the throw site — but it was sent without a function, rendering an anonymous top frame in Sentry.

Before:

$frames[] = [
    'filename' => $error->getFile(),
    'lineno' => $error->getLine(),
    'in_app' => !str_contains($error->getFile(), '/vendor/'),
];

After — named from the callee recorded in the first trace frame:

$throwSite = [
    'filename' => $error->getFile(),
    'lineno' => $error->getLine(),
    'in_app' => !str_contains($error->getFile(), '/vendor/'),
];

if (isset($trace[0]['function'])) {
    $throwSite['function'] = $this->formatFunction($trace[0]);
}

Each exception value also gains module (the exception's namespace) when it has one.

3. Typed constants + honest PHP floor

Before / after:

// Pretty.php
private const RESET = "\033[0m";           // before
private const string RESET = "\033[0m";    // after

// Sentry.php
private const EXPORT_LEVELS = [...];       // before
private const array EXPORT_LEVELS = [...]; // after
private const int MAX_CHAIN_DEPTH = 10;    // new
private const array HANDLED_HTTP_KEYS = [...];
// composer.json
"php": ">=8.2"  // before
"php": ">=8.3"  // after

Note: Storage\Coroutine already used private const string (a PHP 8.3 feature), so the >=8.2 constraint was already broken — 8.2 would fatal on parse. This makes the floor honest, but it is a platform-requirement bump worth flagging in the next release.

Testing

New envelope-level tests in SentryTest.php (decode the actual envelope payload) covering: chain ordering (root cause first), exception_id/parent_id/source linking, handled true for error / false for fatal, module presence for namespaced exceptions and absence for global ones, function names on all frames including the throw site, and the 10-deep chain cap keeping the newest exceptions.

bin/monorepo test span: 105 tests, 218 assertions, all passing. bin/monorepo validate: all packages valid.

Screenshot 2026-07-24 at 9 48 06 AM

…n span Sentry exporter

- Walk the error's getPrevious() chain (capped at 10) and send every link
  as a separate exception value, root cause first, so Sentry renders the
  full cause tree instead of only the outermost exception
- Attach mechanism to each value: type/handled (fatal = unhandled), and
  exception_id/parent_id/source links for chained values
- Name the throw-site frame's enclosing function (was sent anonymous)
- Include the exception's namespace as module per value
- Type all class constants (PHP 8.3) and bump the PHP floor to >=8.3 —
  Storage\Coroutine already used a typed constant, so 8.2 was never
  actually supported
@ChiragAgg5k
ChiragAgg5k requested a review from loks0n as a code owner July 24, 2026 04:10
@greptile-apps

greptile-apps Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds richer Sentry exception payloads and aligns the span package with PHP 8.3.

  • Serializes chained exceptions with mechanism metadata, modules, and named stack frames.
  • Supports an explicit span.handled attribute while retaining severity-based fallback behavior.
  • Types class constants and raises the Composer PHP requirement to 8.3.
  • Adds envelope-level tests for exception chains, handling metadata, modules, frames, and depth limits.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failures remain within the scope of the previous review thread.

Important Files Changed

Filename Overview
packages/span/src/Span/Exporter/Sentry.php Builds structured chained-exception values, supports explicit handling-state overrides, and extracts stack-frame function names.
packages/span/tests/Exporter/SentryTest.php Adds payload-level coverage for exception chains, mechanism metadata, modules, frames, and chain truncation.
packages/span/src/Span/Exporter/Pretty.php Adds PHP 8.3 type declarations to ANSI formatting constants.
packages/span/composer.json Raises the span package's declared PHP requirement from 8.2 to 8.3.

Reviews (3): Last reviewed commit: "(fix): honor explicit span.handled attri..." | Re-trigger Greptile

Comment thread packages/span/src/Span/Exporter/Sentry.php Outdated
@ChiragAgg5k

Copy link
Copy Markdown
Member Author

@greptile review

…entry exporter

- mechanism.handled now prefers a boolean span.handled attribute; the
  level-based heuristic (fatal = unhandled) is only the fallback
- collapse empty test exception class body per pint single_line_empty_body
@ChiragAgg5k
ChiragAgg5k merged commit d0524ca into main Jul 24, 2026
10 checks passed
@ChiragAgg5k
ChiragAgg5k deleted the feat/span-rich-sentry-exceptions branch July 24, 2026 08:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants