Skip to content

diagnostics_channel: opt-in subscriber suppression #63623

Closed as not planned
Closed as not planned

Description

@BridgeAR

What is the problem this feature will solve?

APM agents and other tools that subscribe to diagnostics channels often
need to suppress nested instrumentation when their own internal code calls
into an instrumented library. Example: a tracer's HTTP exporter
sends a span over HTTP, the HTTP client is instrumented, the exporter
recurses unless the nested call is suppressed.

Every implementation I have looked at solves it the same way: a custom
AsyncLocalStorage carrying a { noop: true } marker, plus a wrapper
closure around every subscribe and bindStore registration that reads
the marker and short-circuits. Datadog's dd-trace-js does it and I believe the other major APMs do equivalent things. That wrapper runs on every publish, not
only on the suppression path. Thus, we pay one extra JS frame, one extra ALS lookup,
one extra branch per subscribed handler, on the hot path of every channel
in the process.

What is the feature you are proposing to solve the problem?

Move the suppression check inside Channel.prototype.publish and
Channel.prototype.runStores, gated by per-subscription opt-in:

const { channel, suppressed } = require('node:diagnostics_channel')

const kMyTracer = Symbol('my-tracer')
const ch = channel('apm:foo:bar')

ch.subscribe(handler, { suppressedBy: kMyTracer })
ch.bindStore(als, transform, { suppressedBy: kMyTracer })

suppressed(kMyTracer, () => instrumentedCall())

suppressedBy names the Symbol that identifies the suppression scope. suppressed(symbol, fn) enters that scope on the active AsyncContextFrame. Subscribers that opted in are skipped while the scope is active. Subscribers that did not opt in keep firing. That gives multi-APM coordination, and it does not change semantics for any existing caller.

Wins: The userland wrapper closure on every subscribed handler goes away. The check folds into the publish loop where diagnostic_channel already iterates subscribers, and V8 inlines it against the (always-empty in production) marker.
Vendors stop re-implementing the same ALS dance, each with their own subtle differences in how the marker propagates across setImmediate, queueMicrotask, and unhandled-rejection paths.

There are probably some details to be fledged out but I wanted to get some feedback about the overall idea first. @bengl @timfish @Qard @trentm and others: opinions?

What alternatives have you considered?

No response

Activity

  1. added
    feature requestIssues requesting new Node.js features.
    diagnostics_channelIssues and PRs related to the diagnostics_channel module.
    on May 28, 2026
  2. isaacs commented on May 28, 2026

    @isaacs
    Contributor

    This seems pretty useful and straightforward, and adding the feature in the diagnostics_channel implementation is the correct choke-point to add this kind of functionality.

    My thoughts:

    • If it's not in bun/deno's implementation, then we effectively still need to do some variant of the { noop: true } check in the subscriber function anyway. But other platforms are likely to follow soon-ish, and at least it's forward-looking, so that's good. We'll also have to do this for legacy node versions, for example, so "not everywhere already" is not really an objection per se, of course, but if Bun, Deno, Cloudflare, et al, are opposed in principle, that'd make this less good, for sure.
    • It may invite other features to accrete onto the handler options object. That could be good, but could also be weird/bad, so it's something to be mindful of and maybe at least sketch some thoughts about what should/shouldn't be considered in the future. What other sorts of context or behavior switches might be desirable? What should we be prepared to push back on?
    • Does the suppressedBy value need to be a Symbol per se? Or will any ===-identified value do the trick? (I'm assuming it'd be fine to just say "anything that === the suppressedBy value will suppress the subscriber", because it's stored in a Map or something.)
  3. BridgeAR commented on May 28, 2026

    @BridgeAR
    MemberAuthor

    For bun/deno: we have dc-polyfill that could just introduce the functionality.

    suppressedBy accepting any reference equal data: fine with me. Creating a empty object to use it as reference point should work as well.

  4. bengl commented on May 28, 2026

    @bengl
    Member

    Overall, the intent makes a lot of sense here. I'm a little confused by the API. Wrapping functions generally requires active instrumentation, so I'd like to avoid that if we can. Is something like an enterWith()-esque function possible here? Something like ch.suppressFor(MyTracer), and then it's simply suppressed for rest of the ALS chain. That should cover nearly all use cases (indeed, any Datadog ones I'm aware of), but if it doesn't, a symmetric ch.unsuppressFor(MyTracer) can restore it for the rest of the ALS chain.

    If it's not in bun/deno's implementation ...

    I'd like to see this (or whatever comes of this) implemented in dc-polyfill. (Also, whoops, Ruben beat me to it 😄.)

  5. bengl commented on May 28, 2026

    @bengl
    Member

    Two more suggestions.

    1. s/suppressedBy/subscriberId/g. First of all, it's not who is suppressing it, it's who it's being suppressed for. Second, the ID could have future use cases, and suppression only happens if/when suppress is called.
    2. Maybe ignore instead of suppress. The effect we're going for here is to allow the subscriber to ignore events in particular cases. "Suppress" is a word with a lot of baggage we should maybe try to avoid. I also find it vague enough to find it unclear what the intended effect is without combing through docs.
  6. DivyanshuX9 commented on May 29, 2026

    @DivyanshuX9
    Contributor

    Hey @BridgeAR @bengl @Qard,

    Really glad this is being surfaced as a first-class feature , I've run into
    exactly this recursion problem and the userland ALS dance is painful to get right.

    A few thoughts before I take a stab at implementing this:

    On the API shape:
    I lean toward @bengl's suggestion of separating the identity (subscriberId)
    from the suppression verb. Something like:

    ch.subscribe(handler, { subscriberId: kMyTracer })
    suppressed(kMyTracer, () => doInternalWork())

    This keeps the door open for future uses of the ID (e.g. priority,
    filtering, debugging) without baking "suppression" into the registration
    permanently.

    On the internal implementation:
    My plan would be a single internal AsyncLocalStorage carrying a Set of
    currently active keys, cloned on each suppressed() entry so nested calls
    compose correctly without mutating the parent context. The check would fold
    into the existing subscriber iteration loop in publish() and runStores(),
    so non-opted subscribers pay zero cost.

    On edge cases I want to make sure I cover:

    • Nested suppressed() calls with same key (Set.add is idempotent, safe)
    • Two APMs with different keys not suppressing each other (each key is independent)
    • Async boundaries — Promise, setImmediate, queueMicrotask (ALS handles this already)
    • unsubscribe() correctly cleaning up the subscriberId metadata
    • bindStore() getting the same opt-in skip logic as subscribe()

    One open question:
    Should suppressedBy/subscriberId be restricted to Symbols and objects only
    (reference equality), and throw ERR_INVALID_ARG_TYPE for primitives like
    strings? I think yes — a string key would be too easy to accidentally collide
    across vendors. Symbols feel like the right primitive here.

    On dc-polyfill:
    Happy to mirror the implementation there too so Bun/Deno users aren't left out
    while runtimes catch up.

    Would love a thumbs up on the general direction before I open a PR.
    Happy to start with just lib/diagnostics_channel.js + tests
    and leave the TracingChannel wiring for a follow-up if that's cleaner.

  7. DivyanshuX9 commented on May 29, 2026

    @DivyanshuX9
    Contributor

    I did some and would love to if any of member would guide if its in a right direction or not

    PR #63651

  8. github-actions commented on Aug 28, 2026

    @github-actions
    Contributor

    This issue has been marked as stale due to 90 days of inactivity.
    It will be automatically closed in 30 days if no further activity occurs. If this is still relevant, please leave a comment or update it to keep it open.

  9. added
    staleIssues and PRs marked stale due to inactivity and scheduled for automatic closure.
    on Aug 28, 2026
  10. github-actions commented on Sep 28, 2026

    @github-actions
    Contributor

    This issue has been automatically closed after 30 days of inactivity following its stale status (no activity for a total of 120 days).
    If this is still relevant, feel free to reopen it or leave a comment with additional details so we can continue the discussion.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    diagnostics_channelIssues and PRs related to the diagnostics_channel module.feature requestIssues requesting new Node.js features.staleIssues and PRs marked stale due to inactivity and scheduled for automatic closure.

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions