Skip to content

feat: pg orchestrion instrumentation#21826

Open
isaacs wants to merge 14 commits into
developfrom
isaacs/pg-orchestrion
Open

feat: pg orchestrion instrumentation#21826
isaacs wants to merge 14 commits into
developfrom
isaacs/pg-orchestrion

Conversation

@isaacs

@isaacs isaacs commented Jun 27, 2026

Copy link
Copy Markdown
Member

Add orchestrion instrumentation for Node, Deno, and Bun, covering the pg module.

This basically copies exactly what the mysql integration does, but for postgres.

fix: #20764
fix: JS-2415

@isaacs isaacs requested a review from a team as a code owner June 27, 2026 04:08
@isaacs isaacs requested review from JPeer264, andreiborza and mydea and removed request for a team June 27, 2026 04:08
@linear-code

linear-code Bot commented Jun 27, 2026

Copy link
Copy Markdown

JS-2415

Comment thread packages/server-utils/src/integrations/tracing-channel/postgres.ts Outdated
Comment thread packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts Outdated
@isaacs

isaacs commented Jun 27, 2026

Copy link
Copy Markdown
Member Author

🚲 🏠 API surface choice:

In order to opt into orchestrion diagnostics-channel-injection style instrumentations, which swap out the vendored OTel instrumentations, we call Sentry.experimentalUseDiagnosticsChannelInjection().

This initializes the orchestrion integrations, and uses them instead of the OTel ones. However, this means that the only time that the orchestrion integrations are set up and provided with options, is at that moment of calling experimentalUseDiagnosticsChannelInjection(). For the pg integration, this means that the option to decide to skip the connectSpans happens before Sentry.init() and so has to be passed into that opt-in method like this:

Sentry.experimentalUseDiagnosticsChannelInjection({ postgres: { ignoreConnectSpans: true } });

I don't love this! It exposes the implementation detail in a way that we will likely regret. I kind of hate it, actually.

  • Overloads the method, so we can't just make it a no-op when we make orchestrion the default.
  • Makes the orchestrion instrumentation load differently than the OTel one (which a user configures by initializing the integration and passing it in the integrations array, rather than relying on it being in defaultIntegrations.)

It was a simple duct-tape way forward, but it sucks.

Another approach would be, instead of actually initializing these integrations at that moment to swap in for the OTel ones, just mark that we're going to use the orchestrion ones when we do initialize, and then when it comes time to initialize them, choose which implementation to use, with the configurations provided.

I'm going to try spiking that out here.


EDIT: yeah, fixed it. Much nicer. The Sentry.experimentalUseDiagnosticsChannelInjectionI() method is back to being argument-free, and the orchestrion integrations can be provided with options in the exact same way as their otel counterparts.

Comment thread packages/deno/src/integrations/postgres.ts
@isaacs isaacs force-pushed the isaacs/pg-orchestrion branch from fbbe3ae to 334614a Compare June 27, 2026 23:24
@github-actions

github-actions Bot commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size % Change Change
@sentry/browser 27.62 kB - -
@sentry/browser - with treeshaking flags 26.05 kB - -
@sentry/browser (incl. Tracing) 46.07 kB - -
@sentry/browser (incl. Tracing + Span Streaming) 47.82 kB - -
@sentry/browser (incl. Tracing, Profiling) 50.84 kB - -
@sentry/browser (incl. Tracing, Replay) 85.31 kB - -
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags 74.91 kB - -
@sentry/browser (incl. Tracing, Replay with Canvas) 89.99 kB - -
@sentry/browser (incl. Tracing, Replay, Feedback) 102.67 kB - -
@sentry/browser (incl. Feedback) 44.8 kB - -
@sentry/browser (incl. sendFeedback) 32.42 kB - -
@sentry/browser (incl. FeedbackAsync) 37.55 kB - -
@sentry/browser (incl. Metrics) 28.68 kB - -
@sentry/browser (incl. Logs) 28.93 kB - -
@sentry/browser (incl. Metrics & Logs) 29.61 kB - -
@sentry/react 29.41 kB - -
@sentry/react (incl. Tracing) 48.38 kB - -
@sentry/vue 32.85 kB - -
@sentry/vue (incl. Tracing) 47.93 kB - -
@sentry/svelte 27.64 kB - -
CDN Bundle 30.02 kB - -
CDN Bundle (incl. Tracing) 48.02 kB - -
CDN Bundle (incl. Logs, Metrics) 31.58 kB - -
CDN Bundle (incl. Tracing, Logs, Metrics) 49.35 kB - -
CDN Bundle (incl. Replay, Logs, Metrics) 70.79 kB - -
CDN Bundle (incl. Tracing, Replay) 85.51 kB - -
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) 86.79 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback) 91.32 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) 92.56 kB - -
CDN Bundle - uncompressed 89.42 kB - -
CDN Bundle (incl. Tracing) - uncompressed 145.35 kB - -
CDN Bundle (incl. Logs, Metrics) - uncompressed 94.12 kB - -
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed 149.32 kB - -
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed 218.66 kB - -
CDN Bundle (incl. Tracing, Replay) - uncompressed 264.36 kB - -
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed 268.32 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed 278.06 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed 282.01 kB - -
@sentry/nextjs (client) 50.76 kB - -
@sentry/sveltekit (client) 46.46 kB - -
@sentry/core/server 77.75 kB - -
@sentry/core/browser 64.06 kB - -
@sentry/node-core 61.47 kB - -
@sentry/node 123 kB +0.13% +150 B 🔺
@sentry/node/import (ESM hook with diagnostics-channel injection) 69.95 kB - -
@sentry/node/light 50.45 kB - -
@sentry/node - without tracing 73.2 kB +0.01% +1 B 🔺
@sentry/aws-serverless 84.09 kB - -
@sentry/cloudflare (withSentry) - minified 180.62 kB - -
@sentry/cloudflare (withSentry) 446.93 kB - -

View base workflow run

@isaacs isaacs force-pushed the isaacs/pg-orchestrion branch from 9545bf8 to dfe74f5 Compare June 29, 2026 01:36

@JPeer264 JPeer264 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.

Overall LGTM, got minor suggestions. I'll review once more after the tests are green and the merge conflicts got solved

- name: Set up Deno
if:
matrix.test-application == 'deno' || matrix.test-application == 'deno-streamed' || matrix.test-application ==
'deno-redis' || matrix.test-application == 'hono-4' || matrix.test-application == 'deno-mysql'

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.

q: I wonder if using startsWith(matrix.test-application, 'deno-') would be better at this point

"devDependencies": {
"mysql": "^2.18.1"
"mysql": "^2.18.1",
"pg": "8.16.0"

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/m: I think a good time to revisit this comment: #21451 (comment)


export const denoPostgresIntegration = defineIntegration(_denoPostgresIntegration) as (
options?: DenoPostgresIntegrationOptions,
) => Integration & {

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: Not sure where I've read it, but I think we try to not add a custom type in integrations


// OpenTelemetry "OLD" db/net semantic-conventions, inlined to keep this
// integration free of `@opentelemetry/*` deps.
const ATTR_DB_SYSTEM = 'db.system';

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/m: We could use the new attributes from the @sentry/conventions/attributes already for all that exist. In this case DB_SYSTEM_NAME, then we have already some migrated

return {
[ATTR_DB_SYSTEM]: DB_SYSTEM_POSTGRESQL,
[ATTR_DB_CONNECTION_STRING]: getConnectionString(params),
...(params.database ? { [ATTR_DB_NAME]: params.database } : {}),

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: AFAIK undefined values will be removed, so maybe we can just write the following (not sure about empty strings though)? (same for other occurrences)

Suggested change
...(params.database ? { [ATTR_DB_NAME]: params.database } : {}),
[ATTR_DB_NAME]: params.database,

logaretm and others added 14 commits June 30, 2026 10:32
`bindTracingChannelToSpan` only bound the span store on `start`, which covers
the synchronous frame but not a callback the library dispatches from a detached
async context (e.g. a socket data handler or a `setImmediate` drain). There,
native async-context propagation no longer reaches the caller, so work issued
inside the callback lost its parent.

Stash the caller's store at `start` and re-bind it on `asyncStart`, so
callback-style channels run their continuation in the caller's context — the
same way a promise's `.then` does natively. It's inert for promise channels,
which `publish` `asyncStart` rather than `runStores` it.

Migrate the lru-memoizer subscriber onto the helper (`getSpan` returns
`undefined`, so no span is created — it only needs the context rebind),
dropping its hand-rolled callback re-wrapping.
… caller context

The asyncStart producer used `data._sentryCallerStore ?? asyncLocalStorage.getStore()`.
When the caller had no active store, `_sentryCallerStore` is `undefined` and the
fallback bound whatever was ambient at callback time — for a callback dispatched
from a pooled socket handler, that can be another request's store. `start` always
runs first and sets `_sentryCallerStore`, so return it verbatim (no fallback),
matching the synchronous path: no caller context restores to none, not a foreign one.
…ngChannelToSpan

The mysql subscriber hand-rolled the whole channel lifecycle: a span WeakMap,
a parent-scope WeakMap, manual callback re-wrapping to restore the caller's
context, and per-path span ending. Most of that is what the helper already does
— now that it restores caller context on `asyncStart`, the manual re-wrap can go.

Add a `deferSpanEnd` option to the helper: return `true` to take ownership of
ending the span so it isn't ended on `end`/`asyncEnd`. mysql uses it for the one
path the helper can't model — `query(sql)` with no callback returns a streamed
`Query` emitter that settles via its own `'end'`/`'error'` events, not the
channel. That path keeps `bindScopeToEmitter` so user listeners nest correctly.

Net: the callback and sync-throw paths are fully the helper's; only the
streamed-emitter wiring stays mysql-specific.
The synthetic test published `start`/`asyncStart` with bare `publish`, which
doesn't run bound stores — fine for the old subscriber that opened the span on
`start`, but the span is now opened in the `start.bindStore` transform (only run
via `runStores`). Drive it the way orchestrion's `wrapCallback` actually does so
the bound store activates and the span opens.
…le parity

`deferSpanEnd` handed the caller the span and left them to end it, so the
streamed-mysql path reimplemented a thinner version of the lifecycle — bare
`setStatus`, no `error.type`, no `captureError`, no `beforeSpanEnd`.

Pass an `end(error?)` into `deferSpanEnd` instead: it owns *how* the span ends
(error status + attributes, captureError, beforeSpanEnd) and is idempotent, so
the deferred path can't drift from the channel lifecycle. The error annotation
is factored into a shared helper both paths use. mysql just wires the emitter:
`on('error', end)` / `on('end', end)`.

`deferSpanEnd` now takes a single object arg `{ span, data, end }`.
Add orchestrion instrumentation for Node, Deno, and Bun, covering the
`pg` module.

This basically copies exactly what the `mysql` integration does, but for
postgres.

fix: #20764
fix: JS-2415
This resolves the API surface wart where options for orchestrion
integrations had to be passed into the
`experimentalUseDiagnosticsChannelInjection` method, and returns that to
being an argument-free void-returning opt-in that can be easily no-op'ed
in the future, and all options are passed in via `Sentry.init()` as they
were in the prior OTel implementation.
@isaacs isaacs force-pushed the isaacs/pg-orchestrion branch from dfe74f5 to 373c014 Compare June 30, 2026 23:45

@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.

Want reviews to match your repository better? Bugbot Learning can learn team-specific rules from PR activity. A team admin can enable Learning in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 373c014. Configure here.

...(hasSpansEnabled(options) ? getAutoPerformanceIntegrations() : []),
];

// When the app opted into diagnostics-channel injection (via

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

LruMemoizer orchestrion swap missing

Medium Severity

After moving orchestrion opt-in to per-integration factories, lruMemoizerIntegration still always installs OpenTelemetry instrumentation and never consults getChannelIntegrationFactory, even though experimentalUseDiagnosticsChannelInjection registers LruMemoizer: lruMemoizerChannelIntegration. With orchestrion enabled, lru-memoizer channel hooks are injected but not subscribed, while OTel patching may still run.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 373c014. Configure here.

Comment on lines 161 to 171

if (opts?.captureError) {
captureException(data.error, getErrorHint(data.error));
}

const { message, attributes } = getErrorInfo(data.error);
span.setStatus({ code: SPAN_STATUS_ERROR, message });
span.setAttributes(attributes);
annotateSpanError(span, data.error);
},
asyncEnd(data) {
const span = data._sentrySpan;
if (span && deferSpanEnd?.({ span, data, end: makeDeferredEnd(span, data) })) {
return;
}
endBoundSpan(data, beforeSpanEnd);
},
};

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: Both end and asyncEnd subscribers attach event listeners for promise-based emitter results, leading to duplicate listener registration on the same emitter.
Severity: MEDIUM

Suggested Fix

Prevent the asyncEnd subscriber from calling deferSpanEnd if the end subscriber has already handled it. This can be achieved by storing a flag on the subscriber's data object to indicate that deferral has already occurred, ensuring listeners are attached only once.

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/server-utils/src/tracing-channel.ts#L144-L171

Potential issue: For promise-based operations that return an event emitter, both the
`end` and `asyncEnd` subscribers in `tracing-channel.ts` are invoked. Each of these
subscribers calls `deferSpanEnd`, which then proceeds to attach `'error'` and `'end'`
listeners to the emitter. This results in two identical pairs of event listeners being
registered on the same emitter. While idempotency checks prevent the span from being
ended multiple times, this redundant listener registration is inefficient, can trigger
`MaxListenersExceededWarning` in Node.js, and may cause any configured `beforeSpanEnd`
hook to execute twice.

Also affects:

  • packages/server-utils/src/integrations/tracing-channel/postgres.ts:138~155

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.

Rewrite @opentelemetry/instrumentation-pg to orchestrion

3 participants