Skip to content

feat(core, node): portable Express integration#19928

Open
isaacs wants to merge 5 commits intodevelopfrom
isaacschlueter/portable-express-integration
Open

feat(core, node): portable Express integration#19928
isaacs wants to merge 5 commits intodevelopfrom
isaacschlueter/portable-express-integration

Conversation

@isaacs
Copy link
Member

@isaacs isaacs commented Mar 21, 2026

This extracts the functionality from the OTel Express intstrumentation,
replacing it with a portable standalone integration in @sentry/core,
which can be extended and applied to patch any Express module import in
whatever way makes sense for the platform in question.

Currently in node, that is still an OpenTelemetry intstrumentation, but
just handling the automatic module load instrumentation, not the entire
tracing integration.

This is somewhat a proof of concept, to see what it takes to port a
fairly invovled OTel integration into a state where it can support all
of the platforms that we care about, but it does impose a bit less of a
translation layer between OTel and Sentry semantics (for example, no
need to use the no-op span.recordException()).

User-visible changes (beyond the added export in @sentry/core):

  • Express router spans have an origin of auto.http.express rather than
    auto.http.otel.express, since it's no longer technically an otel
    integration.
  • The empty measurements: {} object is no longer attached to span
    data, as that was an artifact of otel's span.recordError, which is a
    no-op anyway, and no longer executed.

Obviously this is not a full clean-room reimplementation, and relies on
the fact that the opentelemetry-js-contrib project is Apache 2.0
licensed. I included the link to the upstream license in the index file
for the Express integration, but there may be a more appropriate way to
ensure that the license is respected properly. It was arguably a
derivative work already, but simple redistribution is somewhat different
than re-implementation with subtly different context.

This reduces the node overhead and makes the Express instrumentation
portable to other SDKs, but it of course increases the bundle size of
@sentry/core considerably. It would be a good idea to explore
splitting out integrations from core, so that they're bundled and
analyzed separately, rather than shipping to all SDKs that extend core.

Closes #19929 (added automatically)

@github-actions
Copy link
Contributor

github-actions bot commented Mar 21, 2026

Semver Impact of This PR

🟡 Minor (new features)

📋 Changelog Preview

This is how your changes will appear in the changelog.
Entries from this PR are highlighted with a left border (blockquote style).


New Features ✨

  • (core, node) Portable Express integration by isaacs in #19928
  • (node) Add nodeRuntimeMetricsIntegration by chargome in #19923
  • (nuxt) Support parametrized SSR routes in Nuxt 5 by s1gr1d in #19977

Bug Fixes 🐛

  • (opentelemetry) Convert seconds timestamps in span.end() to milliseconds by logaretm in #19958

Documentation 📚

  • (release) Update publishing-a-release.md by nicohrubec in #19982

Internal Changes 🔧

  • (core) Consolidate getOperationName into one shared utility by nicohrubec in #19971
  • (deno) Expand Deno E2E test coverage by chargome in #19957

🤖 This preview updates automatically when you update the PR.

@github-actions
Copy link
Contributor

github-actions bot commented Mar 21, 2026

size-limit report 📦

⚠️ Warning: Base artifact is not the latest one, because the latest workflow run is not done yet. This may lead to incorrect results. Try to re-run all tests to get up to date results.

Path Size % Change Change
@sentry/browser 25.69 kB +0.2% +49 B 🔺
@sentry/browser - with treeshaking flags 24.17 kB +0.14% +33 B 🔺
@sentry/browser (incl. Tracing) 42.67 kB +0.13% +54 B 🔺
@sentry/browser (incl. Tracing, Profiling) 47.33 kB +0.12% +55 B 🔺
@sentry/browser (incl. Tracing, Replay) 81.48 kB +0.08% +57 B 🔺
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags 71.06 kB +0.1% +69 B 🔺
@sentry/browser (incl. Tracing, Replay with Canvas) 86.17 kB +0.06% +50 B 🔺
@sentry/browser (incl. Tracing, Replay, Feedback) 98.41 kB +0.04% +36 B 🔺
@sentry/browser (incl. Feedback) 42.48 kB +0.08% +30 B 🔺
@sentry/browser (incl. sendFeedback) 30.35 kB +0.15% +43 B 🔺
@sentry/browser (incl. FeedbackAsync) 35.4 kB +0.12% +39 B 🔺
@sentry/browser (incl. Metrics) 26.96 kB +0.15% +38 B 🔺
@sentry/browser (incl. Logs) 27.1 kB +0.12% +32 B 🔺
@sentry/browser (incl. Metrics & Logs) 27.78 kB +0.15% +39 B 🔺
@sentry/react 27.45 kB +0.22% +58 B 🔺
@sentry/react (incl. Tracing) 45.01 kB +0.14% +60 B 🔺
@sentry/vue 30.13 kB +0.16% +46 B 🔺
@sentry/vue (incl. Tracing) 44.52 kB +0.09% +39 B 🔺
@sentry/svelte 25.7 kB +0.16% +40 B 🔺
CDN Bundle 28.35 kB +0.27% +75 B 🔺
CDN Bundle (incl. Tracing) 43.57 kB +0.15% +62 B 🔺
CDN Bundle (incl. Logs, Metrics) 29.22 kB +0.27% +77 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics) 44.43 kB +0.17% +75 B 🔺
CDN Bundle (incl. Replay, Logs, Metrics) 68.29 kB +0.13% +85 B 🔺
CDN Bundle (incl. Tracing, Replay) 80.41 kB +0.1% +73 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) 81.31 kB +0.1% +76 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) 85.97 kB +0.12% +103 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) 86.86 kB +0.1% +86 B 🔺
CDN Bundle - uncompressed 82.7 kB +0.1% +77 B 🔺
CDN Bundle (incl. Tracing) - uncompressed 128.62 kB +0.05% +64 B 🔺
CDN Bundle (incl. Logs, Metrics) - uncompressed 85.57 kB +0.1% +77 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed 131.49 kB +0.05% +64 B 🔺
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed 209.22 kB +0.05% +102 B 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed 245.5 kB +0.04% +89 B 🔺
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed 248.35 kB +0.04% +89 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed 258.41 kB +0.04% +89 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed 261.26 kB +0.04% +89 B 🔺
@sentry/nextjs (client) 47.4 kB +0.08% +37 B 🔺
@sentry/sveltekit (client) 43.12 kB +0.12% +51 B 🔺
@sentry/node-core 56.43 kB +0.15% +81 B 🔺
@sentry/node 170.51 kB -1.54% -2.65 kB 🔽
@sentry/node - without tracing 96.47 kB +0.13% +122 B 🔺
@sentry/aws-serverless 113.56 kB +0.2% +217 B 🔺

View base workflow run

@github-actions
Copy link
Contributor

github-actions bot commented Mar 21, 2026

node-overhead report 🧳

Note: This is a synthetic benchmark with a minimal express app and does not necessarily reflect the real-world performance impact in an application.

Scenario Requests/s % of Baseline Prev. Requests/s Change %
GET Baseline 9,444 - 9,359 +1%
GET With Sentry 1,719 18% 1,696 +1%
GET With Sentry (error only) 6,035 64% 6,065 -0%
POST Baseline 1,176 - 1,192 -1%
POST With Sentry 577 49% 567 +2%
POST With Sentry (error only) 1,035 88% 1,017 +2%
MYSQL Baseline 3,182 - 3,183 -0%
MYSQL With Sentry 437 14% 418 +5%
MYSQL With Sentry (error only) 2,601 82% 2,558 +2%

View base workflow run

@isaacs isaacs force-pushed the isaacschlueter/portable-express-integration branch from 3cddcee to dc1b9cf Compare March 21, 2026 23:48
@isaacs isaacs marked this pull request as draft March 21, 2026 23:48
@isaacs isaacs force-pushed the isaacschlueter/portable-express-integration branch 5 times, most recently from 64c45e7 to fd4fefe Compare March 22, 2026 19:04
@isaacs isaacs marked this pull request as ready for review March 22, 2026 21:03
@isaacs isaacs force-pushed the isaacschlueter/portable-express-integration branch from 7e0d74f to db667fc Compare March 23, 2026 03:45
@isaacs isaacs force-pushed the isaacschlueter/portable-express-integration branch 2 times, most recently from b2d4e3c to d5d4e9b Compare March 23, 2026 14:07
@linear-code linear-code bot mentioned this pull request Mar 23, 2026
24 tasks
@isaacs isaacs force-pushed the isaacschlueter/portable-express-integration branch 2 times, most recently from f694a6b to 23f24a1 Compare March 23, 2026 18:10
@isaacs isaacs requested a review from Lms24 March 23, 2026 19:00
@isaacs isaacs force-pushed the isaacschlueter/portable-express-integration branch from 23f24a1 to 37d4883 Compare March 24, 2026 23:53
Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Patched layer.handle becomes non-writable unintentionally
    • Added writable: true to the Object.defineProperty descriptor so patched layer.handle preserves Express's original writability for direct reassignment.

Create PR

Or push these changes by commenting:

@cursor push 6524f61f18
Preview (6524f61f18)
diff --git a/packages/core/src/integrations/express/patch-layer.ts b/packages/core/src/integrations/express/patch-layer.ts
--- a/packages/core/src/integrations/express/patch-layer.ts
+++ b/packages/core/src/integrations/express/patch-layer.ts
@@ -46,6 +46,7 @@
   Object.defineProperty(layer, 'handle', {
     enumerable: true,
     configurable: true,
+    writable: true,
     value: function layerHandlePatched(
       this: ExpressLayer,
       req: ExpressRequest,

This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.

Copy link
Member

@Lms24 Lms24 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for porting this instrumentation! I had some questions and suggestions. I got a bit confused how far along we are with "Sentryfying" the instrumentation. I think it's fine if we make certain changes, like simplifying the span name logic, in follow-up PRs if the goal of this PR was primarily to vendor in code. Then again, we definitely made some changes in there already, so I suggested them anyway. Therefore, feel free to address some of my comments in a follow-up PR.

'express.type': 'request_handler',
'http.route': '/test-transaction',
'sentry.origin': 'auto.http.otel.express',
'sentry.origin': 'auto.http.express',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a comment on the origin change (not just this specific line): I looked up (with Hex) if any alerts, dashboards or saved explore queries depend on sentry.origin (or span.origin which is what the product maps the attribute to for better or worse):

  • Looks like no alerts are configured ✅
  • only 10 dashboard widgets depend on any sentry.origin attributes. All of these are set on the same dashboard for the same org and none depend directly on auto.http.otel.express
  • No discover queries (though Discover is a legacy feature anyway) ✅
  • I didn't yet find any data for saved explore queries, so we're flying blind here for the moment. My guess is usage will be very low/non-existing.

Which means that I think it's fine to change the value now. No need to wait for a new major. Will try to get some more information on saved explore queries but this shouldn't block us.

return express;
};

export const unpatchExpressModule = (options: ExpressIntegrationOptions) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m/Q: do we need unpatching functionality at all? I know OTel has this but I'm not aware of a use case where we'd actually unpatch anything from within our SDK. I'm thinking if we can remove that part of the instrumentation, we'd probably get away with less code to worry about. Though if there's a reason I'm missing to keep it around, that's also totally fine!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is a really interesting point! If we don't have to worry about unpatching, then it really does simplify things quite a lot. No need to keep the original functions around, makes preventing double-wrap easier, etc.

I'm not aware of any need for that either, and I don't see anywhere we do that in our SDKs. So that's likely a good opportunity for simplification of these conversions, I think.

Comment on lines +113 to +114
// TODO: Fix router spans (getRouterPath does not work properly) to
// have useful names before removing this branch
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this a TODO we need to address in this PR or later?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, that's copied in from the original otel express instrumentation, where it arrived January of 2025. It looks like if the layer is a router type, then the name gets messed up.

If we're going to fix it, it could be with our own implementation or by porting a fix from upstream, but it seemed prudent to try to keep this close to the upstream implementation for now, and fix (or remove) the todo later.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah sounds good, we can keep it!

Comment on lines +96 to +110
const spanName = getSpanName(
{
request: req,
layerType: type,
route: constructedRoute,
},
metadata.name,
);
return startSpanManual({ name: spanName, attributes }, span => {
// Also update the name, we don't need to "middleware - " prefix
const name = attributes[ATTR_EXPRESS_NAME];
if (typeof name === 'string') {
// should this be updateSpanName?
span.updateName(name);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m: I see above we already started setting sentry.op and sentry.origin attributes, so I assume this part of the instrumentation is already "Sentryfied", correct?

In this case, does anything hold us back from already setting the correct span name within the startSpanManual options instead of updating it? Ideally we can clean this up. Once that's done, I believe we can get rid of a bit of code in inferSpanData which extracts name, op and sentry.source (IIRC) from spans. Not 100% sure though because this function covers a lot of categories of spans so chances are the express code path is shared with other server frameworks we didn't port yet.

Anyway, we can also do this afterwards but my thinking is that ideally we can set a span name right from the start and don't have to update it later on.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something else that I think we could handle here eventually: We have logic somewhere (sorry I don't recall where exactly but I'm sure we can find it), to update the active root (http.server) span name with the route name when we start a route handler span. At this point, we also need to update the sentry.source attribute of the span so that Relay knows this span has a "good" route name and no longer is a raw URL.

@isaacs isaacs force-pushed the isaacschlueter/portable-express-integration branch from 37d4883 to a564a72 Compare March 25, 2026 23:48
"@tanstack/react-start": "^1.136.0",
"@tanstack/react-router": "^1.136.0",
"@tanstack/react-start": "^1.138.2",
"@tanstack/react-router": "^1.138.2",
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accidentally committed tanstackstart dependency version bump

Low Severity

The @tanstack/react-start and @tanstack/react-router dependencies were bumped from ^1.136.0 to ^1.138.2. The PR author confirmed in discussion this is "debugging scar tissue from tanstack update breaking integration tests the other day" and said they would "back out" the change. This version bump is unrelated to the Express integration work.

Fix in Cursor Fix in Web

// express considers anything but an empty value, "route" or "router"
// passed to its callback to be an error
const maybeError = args[0];
const isError = ![undefined, null, 'route', 'router'].includes(maybeError);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Falsy non-null values incorrectly detected as errors

Low Severity

The error detection ![undefined, null, 'route', 'router'].includes(maybeError) treats falsy values like 0, false, or '' as errors, while Express itself treats only truthy values (other than 'route'/'router') as errors. If middleware calls next(0) or next(false), the span would incorrectly get an error status.

Fix in Cursor Fix in Web

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 3 total unresolved issues (including 2 from previous reviews).

Fix All in Cursor

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

message,
});
throw anyError;
/* v8 ignore next - it sees the block end at the throw */
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing captureException in startSpanManual error path

Medium Severity

The startSpanManual callback has a catch block (line 170) that catches errors thrown by layerHandleOriginal, sets the span status to error, and re-throws — but never calls captureException. Per the review rules, when calling startSpan variants, error cases need to consider calling captureException. The comment says "the error handler we assign does that," but that relies on the user having called setupExpressErrorHandler, which is not guaranteed.

Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

isaacs added 4 commits March 26, 2026 08:47
This extracts the functionality from the OTel Express intstrumentation,
replacing it with a portable standalone integration in `@sentry/core`,
which can be extended and applied to patch any Express module import in
whatever way makes sense for the platform in question.

Currently in node, that is still an OpenTelemetry intstrumentation, but
just handling the automatic module load instrumentation, not the entire
tracing integration.

This is somewhat a proof of concept, to see what it takes to port a
fairly invovled OTel integration into a state where it can support all
of the platforms that we care about, but it does impose a bit less of a
translation layer between OTel and Sentry semantics (for example, no
need to use the no-op `span.recordException()`).

User-visible changes (beyond the added export in `@sentry/core`):

- Express router spans have an origin of `auto.http.express` rather than
  `auto.http.otel.express`, since it's no longer technically an otel
  integration.
- The empty `measurements: {}` object is no longer attached to span
  data, as that was an artifact of otel's `span.recordError`, which is a
  no-op anyway, and no longer executed.

Obviously this is not a full clean-room reimplementation, and relies on
the fact that the opentelemetry-js-contrib project is Apache 2.0
licensed. I included the link to the upstream license in the index file
for the Express integration, but there may be a more appropriate way to
ensure that the license is respected properly. It was arguably a
derivative work already, but simple redistribution is somewhat different
than re-implementation with subtly different context.

This reduces the node overhead and makes the Express instrumentation
portable to other SDKs, but it of course *increases* the bundle size of
`@sentry/core` considerably. It would be a good idea to explore
splitting out integrations from core, so that they're bundled and
analyzed separately, rather than shipping to all SDKs that extend core.
@isaacs isaacs force-pushed the isaacschlueter/portable-express-integration branch from 6e5aa00 to ff8586c Compare March 26, 2026 15:47
@isaacs isaacs force-pushed the isaacschlueter/portable-express-integration branch from ff8586c to c78e960 Compare March 26, 2026 15:50
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.

feat(core, node): portable Express integration

2 participants