Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions src/main/filter-listeners.ts
Original file line number Diff line number Diff line change
@@ -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);
}
25 changes: 3 additions & 22 deletions src/main/utility-processes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
},
Expand Down
116 changes: 116 additions & 0 deletions test/unit/filter-listeners.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading