diff --git a/src/main/filter-listeners.ts b/src/main/filter-listeners.ts new file mode 100644 index 00000000..2931ebad --- /dev/null +++ b/src/main/filter-listeners.ts @@ -0,0 +1,59 @@ +import type { EventEmitter } from 'events'; + +type Listener = (...args: unknown[]) => void; +type WrappedListener = Listener & { listener: Listener }; +type AddMethod = 'on' | 'addListener' | 'prependListener' | 'once' | 'prependOnceListener'; + +/** + * Hides messages that `shouldHide` matches from every listener added to + * `emitter` for `eventName`. + * + * Each wrapper carries a `listener` property that points at the caller's + * function. Node's `removeListener` and `listeners()` look through that + * property, so `off` and `listeners()` behave as on a plain emitter. + * `once` is handled here rather than left to Node, because Node's `once` + * calls `on` and its own wrapper would then be wrapped a second time. + */ +export function hideMatchingMessages( + emitter: EventEmitter, + eventName: string, + shouldHide: (message: unknown) => boolean, +): void { + const originalOn = emitter.on.bind(emitter); + const originalPrepend = emitter.prependListener.bind(emitter); + + function wrap(listener: Listener, once: boolean): WrappedListener { + const wrapper = function (this: unknown, ...args: unknown[]): void { + if (shouldHide(args[0])) { + return; + } + + if (once) { + emitter.removeListener(eventName, wrapper); + } + + listener.apply(this, args); + } as WrappedListener; + + wrapper.listener = listener; + return wrapper; + } + + function install(method: AddMethod, register: EventEmitter['on'], once: boolean): void { + const existing = emitter[method].bind(emitter); + + emitter[method] = (name: string | symbol, listener: Listener): EventEmitter => { + if (name !== eventName) { + return existing(name, listener); + } + + return register(name, wrap(listener, once)); + }; + } + + install('on', originalOn, false); + install('addListener', originalOn, false); + install('prependListener', originalPrepend, false); + install('once', originalOn, true); + install('prependOnceListener', originalPrepend, true); +} diff --git a/src/main/utility-processes.ts b/src/main/utility-processes.ts index e3cb9bc5..2d6f1869 100644 --- a/src/main/utility-processes.ts +++ b/src/main/utility-processes.ts @@ -12,6 +12,7 @@ import { captureEvent, getClient } from '@sentry/node'; import * as electron from 'electron'; import { eventFromEnvelope } from '../common/envelope.js'; import { getMagicMessage, isMagicMessage } from '../common/ipc.js'; +import { hideMatchingMessages } from './filter-listeners.js'; import { applyProcessAttributes, normalizeDynamicSamplingContext } from './ipc.js'; import { mergeEvents } from './merge.js'; import { normalizeSpanStreamingEnvelope } from './normalize.js'; @@ -61,28 +62,8 @@ export function configureUtilityProcessIPC(): void { } }); - // We proxy child.on so we can filter messages from the child SDK and ensure that users do not see them - // eslint-disable-next-line typescript/unbound-method - child.on = new Proxy(child.on, { - apply: (target, thisArg, args: [string, (message: unknown) => unknown]) => { - const [event, listener] = args; - - if (event === 'message') { - return Reflect.apply(target, thisArg, [ - event, - (msg: unknown) => { - if (isMagicMessage(msg)) { - return; - } - - return listener(msg); - }, - ]); - } - - return Reflect.apply(target, thisArg, args); - }, - }); + // Users must not see the messages the child SDK sends us + hideMatchingMessages(child, 'message', isMagicMessage); return child; }, diff --git a/test/unit/filter-listeners.test.ts b/test/unit/filter-listeners.test.ts new file mode 100644 index 00000000..a7237aa9 --- /dev/null +++ b/test/unit/filter-listeners.test.ts @@ -0,0 +1,116 @@ +import { EventEmitter } from 'events'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { hideMatchingMessages } from '../../src/main/filter-listeners'; + +const HIDDEN = { hidden: true }; +const isHidden = (message: unknown): boolean => message === HIDDEN; + +describe('hideMatchingMessages', () => { + let emitter: EventEmitter; + + beforeEach(() => { + emitter = new EventEmitter(); + hideMatchingMessages(emitter, 'message', isHidden); + }); + + test('off removes a listener added with on', () => { + const before = emitter.listenerCount('message'); + + for (let i = 0; i < 1000; i++) { + const listener = (): void => undefined; + emitter.on('message', listener); + emitter.off('message', listener); + } + + expect(emitter.listenerCount('message')).toBe(before); + }); + + test.each(['on', 'addListener', 'prependListener', 'once', 'prependOnceListener'] as const)( + '%s hides matching messages and passes the rest through', + (method) => { + const listener = vi.fn(); + emitter[method]('message', listener); + + emitter.emit('message', HIDDEN); + expect(listener).not.toHaveBeenCalled(); + + emitter.emit('message', 'visible'); + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith('visible'); + }, + ); + + test('listeners are called with the emitter as this', () => { + const listener = vi.fn(function (this: unknown) { + expect(this).toBe(emitter); + }); + emitter.on('message', listener); + + emitter.emit('message', 'visible'); + expect(listener).toHaveBeenCalledTimes(1); + }); + + test('once fires once and removes itself', () => { + const listener = vi.fn(); + emitter.once('message', listener); + + emitter.emit('message', 'first'); + emitter.emit('message', 'second'); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith('first'); + expect(emitter.listenerCount('message')).toBe(0); + }); + + test('a hidden message does not consume a once listener', () => { + const listener = vi.fn(); + emitter.once('message', listener); + + emitter.emit('message', HIDDEN); + expect(emitter.listenerCount('message')).toBe(1); + + emitter.emit('message', 'visible'); + expect(listener).toHaveBeenCalledWith('visible'); + }); + + test('off removes a pending once listener', () => { + const listener = vi.fn(); + emitter.once('message', listener); + emitter.off('message', listener); + + emitter.emit('message', 'visible'); + expect(listener).not.toHaveBeenCalled(); + expect(emitter.listenerCount('message')).toBe(0); + }); + + test('prependListener runs before earlier listeners', () => { + const order: string[] = []; + emitter.on('message', () => order.push('first')); + emitter.prependListener('message', () => order.push('prepended')); + + emitter.emit('message', 'visible'); + expect(order).toEqual(['prepended', 'first']); + }); + + test('listeners() returns the original functions', () => { + const a = (): void => undefined; + const b = (): void => undefined; + emitter.on('message', a); + emitter.once('message', b); + + expect(emitter.listeners('message')).toEqual([a, b]); + }); + + test('other events are untouched', () => { + const listener = vi.fn(); + emitter.on('exit', listener); + emitter.once('exit', listener); + + emitter.emit('exit', HIDDEN); + expect(listener).toHaveBeenCalledTimes(2); + expect(listener).toHaveBeenCalledWith(HIDDEN); + + emitter.off('exit', listener); + expect(emitter.listenerCount('exit')).toBe(0); + }); +});