Skip to content

feat(core): Add extendIntegration method#21759

Open
mydea wants to merge 1 commit into
developfrom
fn/better-integration-extend
Open

feat(core): Add extendIntegration method#21759
mydea wants to merge 1 commit into
developfrom
fn/better-integration-extend

Conversation

@mydea

@mydea mydea commented Jun 24, 2026

Copy link
Copy Markdown
Member

This adds a new method, extendIntegration, that can be used to safely extend an integration.

Today, we have the problem that if we extend an integration (e.g. node extending something from server-utils), we have to manually call the parent integration functions. This is a bit brittle because a) it requires you to know exactly what methods the parent integration has, which we usually want to abstract away from users on purpose. also, if a parent integration changes, this could be forgotten/lost.

Usage:

const _denoMysqlIntegration = (() => {
  const inner = mysqlChannelIntegration();

  return extendIntegration(inner, {
    name: INTEGRATION_NAME,
    setupOnce() {
      setAsyncLocalStorageAsyncContextStrategy();
    },
  });
}) satisfies IntegrationFn;

vs. before:

onst _denoMysqlIntegration = (() => {
  const inner = mysqlChannelIntegration();
  return {
    name: INTEGRATION_NAME,
    setupOnce() {
      setAsyncLocalStorageAsyncContextStrategy();
      inner.setupOnce?.();
    },
  };
}) satisfies IntegrationFn;

In addition, I also improved defineIntegration to maintain a static name, and made all integration names static for easier access.

Note: We have a bunch of places where we started to type integrations manually, e.g.

export const denoMysqlIntegration = defineIntegration(_denoMysqlIntegration) as () => Integration & {
  name: 'DenoMysql';
  setupOnce: () => void;
};

We should revert this in v11. We purposefully did not do this because we want to keep the underlying implementation flexible. I think this was done sometimes to make it easier to extend/call things, but usually this should not be necessary and the extend helper should help with making things type safe a bit easier.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

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.

Reviewed by Cursor Bugbot for commit 9767b7b. Configure here.


const wrappedFunction = function (this: unknown, ...args: unknown[]): unknown {
baseBound(...args);
return extendedBound(...args);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

processEvent chain drops base result

Medium Severity

When extendIntegration wraps overlapping processEvent hooks, it invokes the parent with the original arguments and ignores its return value, then runs the extension with the same original event. Parent mutations, null drops, and async results are not forwarded, so extended integrations can observe or send events the parent meant to filter or replace.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9767b7b. Configure here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

yeah, this is kind of "expected" here. it will not really work for this usecase 🤔 not sure if we should tighten this so it only actually covers certain things (e.g. setup, setupOnce) or keep it generic like it is now 🤔

Basically it does not work for anything that expects a return value from the method.

@github-actions

Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size % Change Change
@sentry/browser 27.47 kB - -
@sentry/browser - with treeshaking flags 25.91 kB - -
@sentry/browser (incl. Tracing) 45.97 kB - -
@sentry/browser (incl. Tracing + Span Streaming) 47.72 kB - -
@sentry/browser (incl. Tracing, Profiling) 50.76 kB - -
@sentry/browser (incl. Tracing, Replay) 85.22 kB - -
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags 74.81 kB - -
@sentry/browser (incl. Tracing, Replay with Canvas) 89.91 kB - -
@sentry/browser (incl. Tracing, Replay, Feedback) 102.57 kB - -
@sentry/browser (incl. Feedback) 44.66 kB - -
@sentry/browser (incl. sendFeedback) 32.26 kB - -
@sentry/browser (incl. FeedbackAsync) 37.4 kB - -
@sentry/browser (incl. Metrics) 28.54 kB - -
@sentry/browser (incl. Logs) 28.78 kB - -
@sentry/browser (incl. Metrics & Logs) 29.47 kB - -
@sentry/react 29.27 kB - -
@sentry/react (incl. Tracing) 48.28 kB - -
@sentry/vue 32.63 kB - -
@sentry/vue (incl. Tracing) 47.84 kB - -
@sentry/svelte 27.5 kB - -
CDN Bundle 29.89 kB - -
CDN Bundle (incl. Tracing) 47.89 kB - -
CDN Bundle (incl. Logs, Metrics) 31.44 kB - -
CDN Bundle (incl. Tracing, Logs, Metrics) 49.24 kB - -
CDN Bundle (incl. Replay, Logs, Metrics) 70.78 kB - -
CDN Bundle (incl. Tracing, Replay) 85.4 kB - -
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) 86.68 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback) 91.19 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) 92.45 kB - -
CDN Bundle - uncompressed 88.94 kB - -
CDN Bundle (incl. Tracing) - uncompressed 145.03 kB - -
CDN Bundle (incl. Logs, Metrics) - uncompressed 93.65 kB - -
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed 149 kB - -
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed 218.62 kB - -
CDN Bundle (incl. Tracing, Replay) - uncompressed 264.05 kB - -
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed 268 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed 277.75 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed 281.69 kB - -
@sentry/nextjs (client) 50.67 kB - -
@sentry/sveltekit (client) 46.37 kB - -
@sentry/core/server 76.57 kB +0.09% +68 B 🔺
@sentry/core/browser 63.7 kB +0.11% +67 B 🔺
@sentry/node-core 61.51 kB - -
@sentry/node 122.65 kB -0.01% -2 B 🔽
@sentry/node/import (ESM hook with diagnostics-channel injection) 69.95 kB - -
@sentry/node/light 50.4 kB - -
@sentry/node - without tracing 73.55 kB - -
@sentry/aws-serverless 84.74 kB - -
@sentry/cloudflare (withSentry) - minified 176.01 kB - -
@sentry/cloudflare (withSentry) 437.76 kB - -

View base workflow run

@mydea mydea marked this pull request as ready for review June 25, 2026 08:41
@mydea mydea requested review from a team as code owners June 25, 2026 08:41
@mydea mydea requested review from JPeer264, Lms24, andreiborza, chargome, logaretm, nicohrubec and s1gr1d and removed request for a team June 25, 2026 08:41
Comment on lines +220 to +222
const wrappedFunction = function (this: unknown, ...args: unknown[]): unknown {
baseBound(...args);
return extendedBound(...args);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bug: The extendIntegration wrapper discards the return value of the base method. For processEvent, this ignores decisions to drop events and creates race conditions with async operations.
Severity: MEDIUM

Suggested Fix

The wrappedFunction inside extendIntegration should handle the return value of the base method. For processEvent, it should await the result if it's a promise and pass the modified event to the extended method. It should also check for null and propagate the decision to drop the event by returning null immediately.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: packages/core/src/integration.ts#L220-L222

Potential issue: The `extendIntegration` function wraps methods by calling the base
integration's method and then the extended integration's method. However, the return
value of the base method call (`baseBound(...args);`) is discarded. This is particularly
problematic for the `processEvent` hook, which can return `null` to drop an event or a
`Promise` with a modified event. By ignoring the return value, the wrapper will not drop
events when the base integration intends to, and it will create a race condition if the
base `processEvent` is asynchronous, as the extended method will be called immediately
with the original event.

Did we get this right? 👍 / 👎 to inform future reviews.

@Lms24 Lms24 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nice addition!

options?: HttpServerSpansIntegrationOptions,
) => Integration & {
name: 'HttpServerSpans';
name: 'Http.ServerSpans';

@Lms24 Lms24 Jun 25, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

m: ist the name change intentional? (I like the new pattern but just wanted to flag)

@mydea mydea Jun 25, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

this was actually incorrect, if you look the actual name of the integration is Http.ServerSpans - one more reason why typing it this way is actually harmful. In v11 we should remove this cast probably and just let this be inferred.

options?: HttpServerIntegrationOptions,
) => Integration & {
name: 'HttpServer';
name: 'Http.Server';

@Lms24 Lms24 Jun 25, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

m: ist the name change intentional? probably a fix for the INTEGRATION_NAME diversion

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

same here, this just fixes the type to the actual name this has 😅

const extendedBound = extendedValue.bind(wrappedIntegration) as typeof extendedValue;

const wrappedFunction = function (this: unknown, ...args: unknown[]): unknown {
baseBound(...args);

@Lms24 Lms24 Jun 25, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

l: we could warn if we see that the return type of baseBound is not undefined

but yeah, as we discussed offline, it's not required to use this helper should we ever extend an integration with process* hooks, so not a blocker for me

const wrappedFunction = function (this: unknown, ...args: unknown[]): unknown {
baseBound(...args);
return extendedBound(...args);
} as (Omit<Base, keyof Extended> & Extended)[Extract<keyof Extended, string>];

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit: maybe this can be extracted as a type to make it more readable

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.

3 participants