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 ✨

  • (browser) Replace element timing spans with metrics by logaretm in #19869
  • (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 🐛

  • (e2e) Pin @opentelemetry/api to 1.9.0 in ts3.8 test app by logaretm in #19992
  • (node) Ensure startNewTrace propagates traceId in OTel environments by logaretm in #19963
  • (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

  • Introduce instrumented method registry for AI integrations by nicohrubec in #19981
  • Consolidate getOperationName into one shared utility by nicohrubec in #19971

Other

  • (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.22% +54 B 🔺
@sentry/browser - with treeshaking flags 24.18 kB +0.16% +38 B 🔺
@sentry/browser (incl. Tracing) 42.18 kB -1.03% -436 B 🔽
@sentry/browser (incl. Tracing, Profiling) 46.8 kB -1.02% -479 B 🔽
@sentry/browser (incl. Tracing, Replay) 80.99 kB -0.54% -434 B 🔽
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags 70.6 kB -0.56% -393 B 🔽
@sentry/browser (incl. Tracing, Replay with Canvas) 85.71 kB -0.48% -413 B 🔽
@sentry/browser (incl. Tracing, Replay, Feedback) 97.97 kB -0.41% -400 B 🔽
@sentry/browser (incl. Feedback) 42.48 kB +0.09% +36 B 🔺
@sentry/browser (incl. sendFeedback) 30.36 kB +0.17% +50 B 🔺
@sentry/browser (incl. FeedbackAsync) 35.41 kB +0.14% +46 B 🔺
@sentry/browser (incl. Metrics) 26.96 kB +0.17% +44 B 🔺
@sentry/browser (incl. Logs) 27.1 kB +0.15% +38 B 🔺
@sentry/browser (incl. Metrics & Logs) 27.79 kB +0.17% +46 B 🔺
@sentry/react 27.45 kB +0.24% +64 B 🔺
@sentry/react (incl. Tracing) 44.53 kB -0.95% -423 B 🔽
@sentry/vue 30.14 kB +0.18% +52 B 🔺
@sentry/vue (incl. Tracing) 44.08 kB -0.9% -399 B 🔽
@sentry/svelte 25.71 kB +0.18% +45 B 🔺
CDN Bundle 28.4 kB +0.43% +119 B 🔺
CDN Bundle (incl. Tracing) 43.2 kB -0.71% -305 B 🔽
CDN Bundle (incl. Logs, Metrics) 29.77 kB +2.14% +621 B 🔺
CDN Bundle (incl. Tracing, Logs, Metrics) 44.26 kB -0.23% -101 B 🔽
CDN Bundle (incl. Replay, Logs, Metrics) 68.56 kB +0.52% +354 B 🔺
CDN Bundle (incl. Tracing, Replay) 80.09 kB -0.31% -246 B 🔽
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) 81.16 kB -0.09% -69 B 🔽
CDN Bundle (incl. Tracing, Replay, Feedback) 85.62 kB -0.29% -249 B 🔽
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) 86.68 kB -0.11% -90 B 🔽
CDN Bundle - uncompressed 82.95 kB +0.4% +326 B 🔺
CDN Bundle (incl. Tracing) - uncompressed 128.09 kB -0.37% -467 B 🔽
CDN Bundle (incl. Logs, Metrics) - uncompressed 87.09 kB +1.88% +1.6 kB 🔺
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed 131.5 kB +0.06% +77 B 🔺
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed 210.08 kB +0.46% +956 B 🔺
CDN Bundle (incl. Tracing, Replay) - uncompressed 244.97 kB -0.19% -442 B 🔽
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed 248.37 kB +0.05% +102 B 🔺
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed 257.88 kB -0.18% -442 B 🔽
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed 261.27 kB +0.04% +102 B 🔺
@sentry/nextjs (client) 46.94 kB -0.91% -431 B 🔽
@sentry/sveltekit (client) 42.68 kB -0.92% -394 B 🔽
@sentry/node-core 56.52 kB +0.32% +175 B 🔺
@sentry/node 173.22 kB +0.04% +67 B 🔺
@sentry/node - without tracing 96.56 kB +0.23% +215 B 🔺
@sentry/aws-serverless 113.56 kB +0.2% +224 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 8,908 - 9,565 -7%
GET With Sentry 1,683 19% 1,659 +1%
GET With Sentry (error only) 5,948 67% 5,943 +0%
POST Baseline 1,169 - 1,201 -3%
POST With Sentry 582 50% 596 -2%
POST With Sentry (error only) 1,028 88% 1,074 -4%
MYSQL Baseline 3,191 - 3,286 -3%
MYSQL With Sentry 441 14% 471 -6%
MYSQL With Sentry (error only) 2,618 82% 2,665 -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.

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
@isaacs isaacs force-pushed the isaacschlueter/portable-express-integration branch 2 times, most recently from ff8586c to c78e960 Compare March 26, 2026 15:50
isaacs added 2 commits March 26, 2026 14:52
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 c78e960 to 3c1ebe0 Compare March 26, 2026 23:38
@isaacs isaacs force-pushed the isaacschlueter/portable-express-integration branch from 2d55c15 to 4adacd5 Compare March 27, 2026 02:54
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.

Fix All in Cursor

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

}
markFunctionWrapped(wrapped, original);
addNonEnumerableProperty(obj, field, wrapped);
}
Copy link

Choose a reason for hiding this comment

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

Wrapped methods become non-enumerable unlike originals

Low Severity

wrapMethod uses addNonEnumerableProperty to assign the wrapped function, which sets enumerable: false via Object.defineProperty. In Express v4, Router.route, Router.use, and application.use are own enumerable properties. After wrapping, they become non-enumerable, which differs from the previous OTel shimmer-based wrapping approach (which used direct assignment, preserving enumerability). Any code checking Object.keys() or for...in on the Router would no longer see these methods.

Additional Locations (1)
Fix in Cursor Fix in Web

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