diff --git a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/package.json b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/package.json index b4d0ead875f9..efc52a8a4db9 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/package.json +++ b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/package.json @@ -18,6 +18,7 @@ "@nestjs/common": "^10.0.0", "@nestjs/core": "^10.0.0", "@nestjs/platform-express": "^10.0.0", + "@nestjs/event-emitter": "^2.0.0", "@sentry/nestjs": "latest || *", "@sentry/types": "latest || *", "reflect-metadata": "^0.2.0", diff --git a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/events.controller.ts b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/events.controller.ts new file mode 100644 index 000000000000..cb5ddebcc3ae --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/events.controller.ts @@ -0,0 +1,14 @@ +import { Controller, Get } from '@nestjs/common'; +import { EventsService } from './events.service'; + +@Controller('events') +export class EventsController { + constructor(private readonly eventsService: EventsService) {} + + @Get('emit') + async emitEvents() { + await this.eventsService.emitEvents(); + + return { message: 'Events emitted' }; + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/events.module.ts b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/events.module.ts new file mode 100644 index 000000000000..b92995e323eb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/events.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; +import { APP_FILTER } from '@nestjs/core'; +import { EventEmitterModule } from '@nestjs/event-emitter'; +import { SentryGlobalFilter, SentryModule } from '@sentry/nestjs/setup'; +import { EventsController } from './events.controller'; +import { EventsService } from './events.service'; +import { TestEventListener } from './listeners/test-event.listener'; + +@Module({ + imports: [SentryModule.forRoot(), EventEmitterModule.forRoot()], + controllers: [EventsController], + providers: [ + { + provide: APP_FILTER, + useClass: SentryGlobalFilter, + }, + EventsService, + TestEventListener, + ], +}) +export class EventsModule {} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/events.service.ts b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/events.service.ts new file mode 100644 index 000000000000..4a9f36ddaf5c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/events.service.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; + +@Injectable() +export class EventsService { + constructor(private readonly eventEmitter: EventEmitter2) {} + + async emitEvents() { + await this.eventEmitter.emit('myEvent.pass', { data: 'test' }); + await this.eventEmitter.emit('myEvent.throw'); + + return { message: 'Events emitted' }; + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/listeners/test-event.listener.ts b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/listeners/test-event.listener.ts new file mode 100644 index 000000000000..c1a3237f1f0c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/listeners/test-event.listener.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; + +@Injectable() +export class TestEventListener { + @OnEvent('myEvent.pass') + async handlePassEvent(payload: any): Promise { + await new Promise(resolve => setTimeout(resolve, 100)); + } + + @OnEvent('myEvent.throw') + async handleThrowEvent(): Promise { + await new Promise(resolve => setTimeout(resolve, 100)); + throw new Error('Test error from event handler'); + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/main.ts b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/main.ts index 5aad5748b244..a18877460852 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/main.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/main.ts @@ -3,11 +3,13 @@ import './instrument'; // Import other modules import { NestFactory } from '@nestjs/core'; +import { EventsModule } from './events.module'; import { TraceInitiatorModule } from './trace-initiator.module'; import { TraceReceiverModule } from './trace-receiver.module'; const TRACE_INITIATOR_PORT = 3030; const TRACE_RECEIVER_PORT = 3040; +const EVENTS_PORT = 3050; async function bootstrap() { const trace_initiator_app = await NestFactory.create(TraceInitiatorModule); @@ -15,6 +17,9 @@ async function bootstrap() { const trace_receiver_app = await NestFactory.create(TraceReceiverModule); await trace_receiver_app.listen(TRACE_RECEIVER_PORT); + + const events_app = await NestFactory.create(EventsModule); + await events_app.listen(EVENTS_PORT); } bootstrap(); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/tests/events.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/tests/events.test.ts new file mode 100644 index 000000000000..b09eabb38980 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/tests/events.test.ts @@ -0,0 +1,43 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test('Event emitter', async () => { + const eventErrorPromise = waitForError('nestjs-distributed-tracing', errorEvent => { + return errorEvent.exception.values[0].value === 'Test error from event handler'; + }); + const successEventTransactionPromise = waitForTransaction('nestjs-distributed-tracing', transactionEvent => { + return transactionEvent.transaction === 'event myEvent.pass'; + }); + + const eventsUrl = `http://localhost:3050/events/emit`; + await fetch(eventsUrl); + + const eventError = await eventErrorPromise; + const successEventTransaction = await successEventTransactionPromise; + + expect(eventError.exception).toEqual({ + values: [ + { + type: 'Error', + value: 'Test error from event handler', + stacktrace: expect.any(Object), + mechanism: expect.any(Object), + }, + ], + }); + + expect(successEventTransaction.contexts.trace).toEqual({ + parent_span_id: expect.any(String), + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.source': 'custom', + 'sentry.sample_rate': 1, + 'sentry.op': 'event.nestjs', + 'sentry.origin': 'auto.event.nestjs', + }, + origin: 'auto.event.nestjs', + op: 'event.nestjs', + status: 'ok', + }); +}); diff --git a/packages/nestjs/package.json b/packages/nestjs/package.json index 9b7360a45706..76028b27e40f 100644 --- a/packages/nestjs/package.json +++ b/packages/nestjs/package.json @@ -50,8 +50,8 @@ "@sentry/utils": "8.38.0" }, "devDependencies": { - "@nestjs/common": "10.4.7", - "@nestjs/core": "10.4.7" + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0" }, "peerDependencies": { "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", diff --git a/packages/node/src/integrations/tracing/nest/helpers.ts b/packages/node/src/integrations/tracing/nest/helpers.ts index cc83dda3855d..04dab67f65b0 100644 --- a/packages/node/src/integrations/tracing/nest/helpers.ts +++ b/packages/node/src/integrations/tracing/nest/helpers.ts @@ -36,6 +36,24 @@ export function getMiddlewareSpanOptions(target: InjectableTarget | CatchTarget, }; } +/** + * Returns span options for nest event spans. + */ +export function getEventSpanOptions(event: string): { + name: string; + attributes: Record; + forceTransaction: boolean; +} { + return { + name: `event ${event}`, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'event.nestjs', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.event.nestjs', + }, + forceTransaction: true, + }; +} + /** * Adds instrumentation to a js observable and attaches the span to an active parent span. */ diff --git a/packages/node/src/integrations/tracing/nest/nest.ts b/packages/node/src/integrations/tracing/nest/nest.ts index 4f8d88fa8f86..2520367d1361 100644 --- a/packages/node/src/integrations/tracing/nest/nest.ts +++ b/packages/node/src/integrations/tracing/nest/nest.ts @@ -12,6 +12,7 @@ import { import type { IntegrationFn, Span } from '@sentry/types'; import { logger } from '@sentry/utils'; import { generateInstrumentOnce } from '../../../otel/instrument'; +import { SentryNestEventInstrumentation } from './sentry-nest-event-instrumentation'; import { SentryNestInstrumentation } from './sentry-nest-instrumentation'; import type { MinimalNestJsApp, NestJsErrorFilter } from './types'; @@ -25,10 +26,15 @@ const instrumentNestCommon = generateInstrumentOnce('Nest-Common', () => { return new SentryNestInstrumentation(); }); +const instrumentNestEvent = generateInstrumentOnce('Nest-Event', () => { + return new SentryNestEventInstrumentation(); +}); + export const instrumentNest = Object.assign( (): void => { instrumentNestCore(); instrumentNestCommon(); + instrumentNestEvent(); }, { id: INTEGRATION_NAME }, ); diff --git a/packages/node/src/integrations/tracing/nest/sentry-nest-event-instrumentation.ts b/packages/node/src/integrations/tracing/nest/sentry-nest-event-instrumentation.ts new file mode 100644 index 000000000000..16333c7fc6c3 --- /dev/null +++ b/packages/node/src/integrations/tracing/nest/sentry-nest-event-instrumentation.ts @@ -0,0 +1,119 @@ +import { isWrapped } from '@opentelemetry/core'; +import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; +import { + InstrumentationBase, + InstrumentationNodeModuleDefinition, + InstrumentationNodeModuleFile, +} from '@opentelemetry/instrumentation'; +import { captureException, startSpan } from '@sentry/core'; +import { SDK_VERSION } from '@sentry/utils'; +import { getEventSpanOptions } from './helpers'; +import type { OnEventTarget } from './types'; + +const supportedVersions = ['>=2.0.0']; + +/** + * Custom instrumentation for nestjs event-emitter + * + * This hooks into the `OnEvent` decorator, which is applied on event handlers. + */ +export class SentryNestEventInstrumentation extends InstrumentationBase { + public static readonly COMPONENT = '@nestjs/event-emitter'; + public static readonly COMMON_ATTRIBUTES = { + component: SentryNestEventInstrumentation.COMPONENT, + }; + + public constructor(config: InstrumentationConfig = {}) { + super('sentry-nestjs-event', SDK_VERSION, config); + } + + /** + * Initializes the instrumentation by defining the modules to be patched. + */ + public init(): InstrumentationNodeModuleDefinition { + const moduleDef = new InstrumentationNodeModuleDefinition( + SentryNestEventInstrumentation.COMPONENT, + supportedVersions, + ); + + moduleDef.files.push(this._getOnEventFileInstrumentation(supportedVersions)); + return moduleDef; + } + + /** + * Wraps the @OnEvent decorator. + */ + private _getOnEventFileInstrumentation(versions: string[]): InstrumentationNodeModuleFile { + return new InstrumentationNodeModuleFile( + '@nestjs/event-emitter/dist/decorators/on-event.decorator.js', + versions, + (moduleExports: { OnEvent: OnEventTarget }) => { + if (isWrapped(moduleExports.OnEvent)) { + this._unwrap(moduleExports, 'OnEvent'); + } + this._wrap(moduleExports, 'OnEvent', this._createWrapOnEvent()); + return moduleExports; + }, + (moduleExports: { OnEvent: OnEventTarget }) => { + this._unwrap(moduleExports, 'OnEvent'); + }, + ); + } + + /** + * Creates a wrapper function for the @OnEvent decorator. + */ + private _createWrapOnEvent() { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return function wrapOnEvent(original: any) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return function wrappedOnEvent(event: any, options?: any) { + const eventName = Array.isArray(event) + ? event.join(',') + : typeof event === 'string' || typeof event === 'symbol' + ? event.toString() + : ''; + + // Get the original decorator result + const decoratorResult = original(event, options); + + // Return a new decorator function that wraps the handler + return function (target: OnEventTarget, propertyKey: string | symbol, descriptor: PropertyDescriptor) { + if (!descriptor.value || typeof descriptor.value !== 'function' || target.__SENTRY_INTERNAL__) { + return decoratorResult(target, propertyKey, descriptor); + } + + // Get the original handler + const originalHandler = descriptor.value; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const handlerName = originalHandler.name || propertyKey; + + // Instrument the handler + // eslint-disable-next-line @typescript-eslint/no-explicit-any + descriptor.value = async function (...args: any[]) { + return startSpan(getEventSpanOptions(eventName), async () => { + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const result = await originalHandler.apply(this, args); + return result; + } catch (error) { + // exceptions from event handlers are not caught by global error filter + captureException(error); + throw error; + } + }); + }; + + // Preserve the original function name + Object.defineProperty(descriptor.value, 'name', { + value: handlerName, + configurable: true, + }); + + // Apply the original decorator + return decoratorResult(target, propertyKey, descriptor); + }; + }; + }; + } +} diff --git a/packages/node/src/integrations/tracing/nest/types.ts b/packages/node/src/integrations/tracing/nest/types.ts index 0590462c09d5..ed7e968a9600 100644 --- a/packages/node/src/integrations/tracing/nest/types.ts +++ b/packages/node/src/integrations/tracing/nest/types.ts @@ -74,6 +74,15 @@ export interface CatchTarget { }; } +/** + * Represents a target method in NestJS annotated with @OnEvent. + */ +export interface OnEventTarget { + name: string; + sentryPatched?: boolean; + __SENTRY_INTERNAL__?: boolean; +} + /** * Represents an express NextFunction. */ diff --git a/packages/node/test/integrations/tracing/nest.test.ts b/packages/node/test/integrations/tracing/nest.test.ts index 3837e3e4ee3d..7f592a93f341 100644 --- a/packages/node/test/integrations/tracing/nest.test.ts +++ b/packages/node/test/integrations/tracing/nest.test.ts @@ -1,5 +1,8 @@ +import * as core from '@sentry/core'; import { isPatched } from '../../../src/integrations/tracing/nest/helpers'; +import { SentryNestEventInstrumentation } from '../../../src/integrations/tracing/nest/sentry-nest-event-instrumentation'; import type { InjectableTarget } from '../../../src/integrations/tracing/nest/types'; +import type { OnEventTarget } from '../../../src/integrations/tracing/nest/types'; describe('Nest', () => { describe('isPatched', () => { @@ -14,4 +17,99 @@ describe('Nest', () => { expect(target.sentryPatched).toBe(true); }); }); + + describe('EventInstrumentation', () => { + let instrumentation: SentryNestEventInstrumentation; + let mockOnEvent: jest.Mock; + let mockTarget: OnEventTarget; + + beforeEach(() => { + instrumentation = new SentryNestEventInstrumentation(); + // Mock OnEvent to return a function that applies the descriptor + mockOnEvent = jest.fn().mockImplementation(() => { + return (target: any, propertyKey: string, descriptor: PropertyDescriptor) => { + return descriptor; + }; + }); + mockTarget = { + name: 'TestClass', + prototype: {}, + } as OnEventTarget; + jest.spyOn(core, 'startSpan'); + jest.spyOn(core, 'captureException'); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('init()', () => { + it('should return module definition with correct component name', () => { + const moduleDef = instrumentation.init(); + expect(moduleDef.name).toBe('@nestjs/event-emitter'); + }); + }); + + describe('OnEvent decorator wrapping', () => { + let wrappedOnEvent: any; + let descriptor: PropertyDescriptor; + let originalHandler: jest.Mock; + + beforeEach(() => { + originalHandler = jest.fn().mockResolvedValue('result'); + descriptor = { + value: originalHandler, + }; + + const moduleDef = instrumentation.init(); + const onEventFile = moduleDef.files[0]; + const moduleExports = { OnEvent: mockOnEvent }; + onEventFile?.patch(moduleExports); + wrappedOnEvent = moduleExports.OnEvent; + }); + + it('should wrap string event handlers', async () => { + const decorated = wrappedOnEvent('test.event'); + decorated(mockTarget, 'testMethod', descriptor); + + await descriptor.value(); + + expect(core.startSpan).toHaveBeenCalled(); + expect(originalHandler).toHaveBeenCalled(); + }); + + it('should wrap array event handlers', async () => { + const decorated = wrappedOnEvent(['test.event1', 'test.event2']); + decorated(mockTarget, 'testMethod', descriptor); + + await descriptor.value(); + + expect(core.startSpan).toHaveBeenCalled(); + expect(originalHandler).toHaveBeenCalled(); + }); + + it('should capture exceptions and rethrow', async () => { + const error = new Error('Test error'); + originalHandler.mockRejectedValue(error); + + const decorated = wrappedOnEvent('test.event'); + decorated(mockTarget, 'testMethod', descriptor); + + await expect(descriptor.value()).rejects.toThrow(error); + expect(core.captureException).toHaveBeenCalledWith(error); + }); + + it('should skip wrapping for internal Sentry handlers', () => { + const internalTarget = { + ...mockTarget, + __SENTRY_INTERNAL__: true, + }; + + const decorated = wrappedOnEvent('test.event'); + decorated(internalTarget, 'testMethod', descriptor); + + expect(descriptor.value).toBe(originalHandler); + }); + }); + }); }); diff --git a/yarn.lock b/yarn.lock index 01efab263fca..230246b7dfdc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6632,7 +6632,7 @@ iterare "1.2.1" tslib "2.7.0" -"@nestjs/common@10.4.7": +"@nestjs/common@^8.0.0 || ^9.0.0 || ^10.0.0": version "10.4.7" resolved "https://registry.yarnpkg.com/@nestjs/common/-/common-10.4.7.tgz#076cb77c06149805cb1e193d8cdc69bbe8446c75" integrity sha512-gIOpjD3Mx8gfYGxYm/RHPcJzqdknNNFCyY+AxzBT3gc5Xvvik1Dn5OxaMGw5EbVfhZgJKVP0n83giUOAlZQe7w== @@ -6653,7 +6653,7 @@ path-to-regexp "3.3.0" tslib "2.7.0" -"@nestjs/core@10.4.7": +"@nestjs/core@^8.0.0 || ^9.0.0 || ^10.0.0": version "10.4.7" resolved "https://registry.yarnpkg.com/@nestjs/core/-/core-10.4.7.tgz#adb27067a8c40b79f0713b417457fdfc6cf3406a" integrity sha512-AIpQzW/vGGqSLkKvll1R7uaSNv99AxZI2EFyVJPNGDgFsfXaohfV1Ukl6f+s75Km+6Fj/7aNl80EqzNWQCS8Ig== @@ -6665,6 +6665,13 @@ path-to-regexp "3.3.0" tslib "2.7.0" +"@nestjs/event-emitter@^2.0.0": + version "2.1.1" + resolved "https://registry.yarnpkg.com/@nestjs/event-emitter/-/event-emitter-2.1.1.tgz#4e34edc487c507edbe6d02033e3dd014a19210f9" + integrity sha512-6L6fBOZTyfFlL7Ih/JDdqlCzZeCW0RjCX28wnzGyg/ncv5F/EOeT1dfopQr1loBRQ3LTgu8OWM7n4zLN4xigsg== + dependencies: + eventemitter2 "6.4.9" + "@nestjs/platform-express@10.4.6": version "10.4.6" resolved "https://registry.yarnpkg.com/@nestjs/platform-express/-/platform-express-10.4.6.tgz#6c39c522fa66036b4256714fea203fbeb49fc4de" @@ -12955,25 +12962,7 @@ bluebird@^3.4.6, bluebird@^3.7.2: resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== -body-parser@1.20.3, body-parser@^1.18.3, body-parser@^1.19.0: - version "1.20.3" - resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.3.tgz#1953431221c6fb5cd63c4b36d53fab0928e548c6" - integrity sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g== - dependencies: - bytes "3.1.2" - content-type "~1.0.5" - debug "2.6.9" - depd "2.0.0" - destroy "1.2.0" - http-errors "2.0.0" - iconv-lite "0.4.24" - on-finished "2.4.1" - qs "6.13.0" - raw-body "2.5.2" - type-is "~1.6.18" - unpipe "1.0.0" - -body-parser@^1.20.3: +body-parser@1.20.3, body-parser@^1.18.3, body-parser@^1.19.0, body-parser@^1.20.3: version "1.20.3" resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.3.tgz#1953431221c6fb5cd63c4b36d53fab0928e548c6" integrity sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g== @@ -18007,6 +17996,11 @@ eventemitter-asyncresource@^1.0.0: resolved "https://registry.yarnpkg.com/eventemitter-asyncresource/-/eventemitter-asyncresource-1.0.0.tgz#734ff2e44bf448e627f7748f905d6bdd57bdb65b" integrity sha512-39F7TBIV0G7gTelxwbEqnwhp90eqCPON1k0NwNfwhgKn4Co4ybUbj2pECcXT0B3ztRKZ7Pw1JujUUgmQJHcVAQ== +eventemitter2@6.4.9: + version "6.4.9" + resolved "https://registry.yarnpkg.com/eventemitter2/-/eventemitter2-6.4.9.tgz#41f2750781b4230ed58827bc119d293471ecb125" + integrity sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg== + eventemitter3@^4.0.0, eventemitter3@^4.0.4: version "4.0.7" resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" @@ -28354,13 +28348,6 @@ qs@^6.4.0: dependencies: side-channel "^1.0.4" -qs@6.13.0: - version "6.13.0" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.13.0.tgz#6ca3bd58439f7e245655798997787b0d88a51906" - integrity sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg== - dependencies: - side-channel "^1.0.6" - query-string@^4.2.2: version "4.3.4" resolved "https://registry.yarnpkg.com/query-string/-/query-string-4.3.4.tgz#bbb693b9ca915c232515b228b1a02b609043dbeb"