diff --git a/packages/core/src/sdk.ts b/packages/core/src/sdk.ts index ef5f51a87b13..3356774e14da 100644 --- a/packages/core/src/sdk.ts +++ b/packages/core/src/sdk.ts @@ -1,4 +1,4 @@ -import type { Client, ClientOptions } from '@sentry/types'; +import type { Client, ClientOptions, Hub as HubInterface } from '@sentry/types'; import { consoleSandbox, logger } from '@sentry/utils'; import { getCurrentScope } from './currentScopes'; @@ -43,10 +43,19 @@ export function initAndBind( * Make the given client the current client. */ export function setCurrentClient(client: Client): void { + getCurrentScope().setClient(client); + + // is there a hub too? // eslint-disable-next-line deprecation/deprecation - const hub = getCurrentHub() as Hub; + const hub = getCurrentHub(); + if (isHubClass(hub)) { + // eslint-disable-next-line deprecation/deprecation + const top = hub.getStackTop(); + top.client = client; + } +} + +function isHubClass(hub: HubInterface): hub is Hub { // eslint-disable-next-line deprecation/deprecation - const top = hub.getStackTop(); - top.client = client; - top.scope.setClient(client); + return !!(hub as Hub).getStackTop; } diff --git a/packages/node-experimental/.eslintrc.js b/packages/node-experimental/.eslintrc.js index 9899ea1b73d8..bec6469d0e28 100644 --- a/packages/node-experimental/.eslintrc.js +++ b/packages/node-experimental/.eslintrc.js @@ -5,5 +5,8 @@ module.exports = { extends: ['../../.eslintrc.js'], rules: { '@sentry-internal/sdk/no-optional-chaining': 'off', + '@sentry-internal/sdk/no-nullish-coalescing': 'off', + '@sentry-internal/sdk/no-unsupported-es6-methods': 'off', + '@sentry-internal/sdk/no-class-field-initializers': 'off', }, }; diff --git a/packages/node-experimental/package.json b/packages/node-experimental/package.json index 5ee77ee6fc00..e3805a603faa 100644 --- a/packages/node-experimental/package.json +++ b/packages/node-experimental/package.json @@ -53,7 +53,8 @@ "@sentry/node": "7.100.0", "@sentry/opentelemetry": "7.100.0", "@sentry/types": "7.100.0", - "@sentry/utils": "7.100.0" + "@sentry/utils": "7.100.0", + "@types/node": "14.18.63" }, "optionalDependencies": { "opentelemetry-instrumentation-fetch-node": "1.1.0" diff --git a/packages/node-experimental/src/integrations/http.ts b/packages/node-experimental/src/integrations/http.ts index bc01d3dad071..14b163e5a309 100644 --- a/packages/node-experimental/src/integrations/http.ts +++ b/packages/node-experimental/src/integrations/http.ts @@ -1,4 +1,4 @@ -import type { ClientRequest, IncomingMessage, ServerResponse } from 'http'; +import type { ClientRequest, ServerResponse } from 'http'; import type { Span } from '@opentelemetry/api'; import { SpanKind } from '@opentelemetry/api'; import { registerInstrumentations } from '@opentelemetry/instrumentation'; @@ -9,6 +9,7 @@ import { _INTERNAL, getClient, getSpanKind, setSpanMetadata } from '@sentry/open import type { IntegrationFn } from '@sentry/types'; import { setIsolationScope } from '../sdk/scope'; +import type { HTTPModuleRequestIncomingMessage } from '../transports/http-module'; import { addOriginToSpan } from '../utils/addOriginToSpan'; import { getRequestUrl } from '../utils/getRequestUrl'; @@ -107,16 +108,16 @@ const _httpIntegration = ((options: HttpOptions = {}) => { export const httpIntegration = defineIntegration(_httpIntegration); /** Update the span with data we need. */ -function _updateSpan(span: Span, request: ClientRequest | IncomingMessage): void { +function _updateSpan(span: Span, request: ClientRequest | HTTPModuleRequestIncomingMessage): void { addOriginToSpan(span, 'auto.http.otel.http'); if (getSpanKind(span) === SpanKind.SERVER) { - setSpanMetadata(span, { request }); + setSpanMetadata(span, { request: request as HTTPModuleRequestIncomingMessage }); } } /** Add a breadcrumb for outgoing requests. */ -function _addRequestBreadcrumb(span: Span, response: IncomingMessage | ServerResponse): void { +function _addRequestBreadcrumb(span: Span, response: HTTPModuleRequestIncomingMessage | ServerResponse): void { if (getSpanKind(span) !== SpanKind.CLIENT) { return; } diff --git a/packages/node-experimental/src/proxy/base.ts b/packages/node-experimental/src/proxy/base.ts new file mode 100644 index 000000000000..e1ef24c3092e --- /dev/null +++ b/packages/node-experimental/src/proxy/base.ts @@ -0,0 +1,151 @@ +/** + * This code was originally forked from https://github.com/TooTallNate/proxy-agents/tree/b133295fd16f6475578b6b15bd9b4e33ecb0d0b7 + * With the following licence: + * + * (The MIT License) + * + * Copyright (c) 2013 Nathan Rajlich * + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * 'Software'), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions:* + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software.* + * + * THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/* eslint-disable @typescript-eslint/explicit-member-accessibility */ +/* eslint-disable @typescript-eslint/member-ordering */ +/* eslint-disable jsdoc/require-jsdoc */ +import * as http from 'http'; +import type * as net from 'net'; +import type { Duplex } from 'stream'; +import type * as tls from 'tls'; + +export * from './helpers'; + +interface HttpConnectOpts extends net.TcpNetConnectOpts { + secureEndpoint: false; + protocol?: string; +} + +interface HttpsConnectOpts extends tls.ConnectionOptions { + secureEndpoint: true; + protocol?: string; + port: number; +} + +export type AgentConnectOpts = HttpConnectOpts | HttpsConnectOpts; + +const INTERNAL = Symbol('AgentBaseInternalState'); + +interface InternalState { + defaultPort?: number; + protocol?: string; + currentSocket?: Duplex; +} + +export abstract class Agent extends http.Agent { + private [INTERNAL]: InternalState; + + // Set by `http.Agent` - missing from `@types/node` + options!: Partial; + keepAlive!: boolean; + + constructor(opts?: http.AgentOptions) { + super(opts); + this[INTERNAL] = {}; + } + + abstract connect( + req: http.ClientRequest, + options: AgentConnectOpts, + ): Promise | Duplex | http.Agent; + + /** + * Determine whether this is an `http` or `https` request. + */ + isSecureEndpoint(options?: AgentConnectOpts): boolean { + if (options) { + // First check the `secureEndpoint` property explicitly, since this + // means that a parent `Agent` is "passing through" to this instance. + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access + if (typeof (options as any).secureEndpoint === 'boolean') { + return options.secureEndpoint; + } + + // If no explicit `secure` endpoint, check if `protocol` property is + // set. This will usually be the case since using a full string URL + // or `URL` instance should be the most common usage. + if (typeof options.protocol === 'string') { + return options.protocol === 'https:'; + } + } + + // Finally, if no `protocol` property was set, then fall back to + // checking the stack trace of the current call stack, and try to + // detect the "https" module. + const { stack } = new Error(); + if (typeof stack !== 'string') return false; + return stack.split('\n').some(l => l.indexOf('(https.js:') !== -1 || l.indexOf('node:https:') !== -1); + } + + createSocket(req: http.ClientRequest, options: AgentConnectOpts, cb: (err: Error | null, s?: Duplex) => void): void { + const connectOpts = { + ...options, + secureEndpoint: this.isSecureEndpoint(options), + }; + Promise.resolve() + .then(() => this.connect(req, connectOpts)) + .then(socket => { + if (socket instanceof http.Agent) { + // @ts-expect-error `addRequest()` isn't defined in `@types/node` + return socket.addRequest(req, connectOpts); + } + this[INTERNAL].currentSocket = socket; + // @ts-expect-error `createSocket()` isn't defined in `@types/node` + super.createSocket(req, options, cb); + }, cb); + } + + createConnection(): Duplex { + const socket = this[INTERNAL].currentSocket; + this[INTERNAL].currentSocket = undefined; + if (!socket) { + throw new Error('No socket was returned in the `connect()` function'); + } + return socket; + } + + get defaultPort(): number { + return this[INTERNAL].defaultPort ?? (this.protocol === 'https:' ? 443 : 80); + } + + set defaultPort(v: number) { + if (this[INTERNAL]) { + this[INTERNAL].defaultPort = v; + } + } + + get protocol(): string { + return this[INTERNAL].protocol ?? (this.isSecureEndpoint() ? 'https:' : 'http:'); + } + + set protocol(v: string) { + if (this[INTERNAL]) { + this[INTERNAL].protocol = v; + } + } +} diff --git a/packages/node-experimental/src/proxy/helpers.ts b/packages/node-experimental/src/proxy/helpers.ts new file mode 100644 index 000000000000..119ffd9317ce --- /dev/null +++ b/packages/node-experimental/src/proxy/helpers.ts @@ -0,0 +1,71 @@ +/** + * This code was originally forked from https://github.com/TooTallNate/proxy-agents/tree/b133295fd16f6475578b6b15bd9b4e33ecb0d0b7 + * With the following licence: + * + * (The MIT License) + * + * Copyright (c) 2013 Nathan Rajlich * + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * 'Software'), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions:* + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software.* + * + * THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/* eslint-disable jsdoc/require-jsdoc */ +import * as http from 'http'; +import * as https from 'https'; +import type { Readable } from 'stream'; +// TODO (v8): Remove this when Node < 12 is no longer supported +import type { URL } from 'url'; + +export type ThenableRequest = http.ClientRequest & { + then: Promise['then']; +}; + +export async function toBuffer(stream: Readable): Promise { + let length = 0; + const chunks: Buffer[] = []; + for await (const chunk of stream) { + length += (chunk as Buffer).length; + chunks.push(chunk); + } + return Buffer.concat(chunks, length); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export async function json(stream: Readable): Promise { + const buf = await toBuffer(stream); + const str = buf.toString('utf8'); + try { + return JSON.parse(str); + } catch (_err: unknown) { + const err = _err as Error; + err.message += ` (input: ${str})`; + throw err; + } +} + +export function req(url: string | URL, opts: https.RequestOptions = {}): ThenableRequest { + const href = typeof url === 'string' ? url : url.href; + const req = (href.startsWith('https:') ? https : http).request(url, opts) as ThenableRequest; + const promise = new Promise((resolve, reject) => { + req.once('response', resolve).once('error', reject).end() as unknown as ThenableRequest; + }); + req.then = promise.then.bind(promise); + return req; +} diff --git a/packages/node-experimental/src/proxy/index.ts b/packages/node-experimental/src/proxy/index.ts new file mode 100644 index 000000000000..4129a9f65cd7 --- /dev/null +++ b/packages/node-experimental/src/proxy/index.ts @@ -0,0 +1,226 @@ +/** + * This code was originally forked from https://github.com/TooTallNate/proxy-agents/tree/b133295fd16f6475578b6b15bd9b4e33ecb0d0b7 + * With the following licence: + * + * (The MIT License) + * + * Copyright (c) 2013 Nathan Rajlich * + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * 'Software'), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions:* + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software.* + * + * THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/* eslint-disable @typescript-eslint/explicit-member-accessibility */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import assert from 'assert'; +import type * as http from 'http'; +import type { OutgoingHttpHeaders } from 'http'; +import * as net from 'net'; +import * as tls from 'tls'; +// TODO (v8): Remove this when Node < 12 is no longer supported +import { URL } from 'url'; +import { logger } from '@sentry/utils'; +import { Agent } from './base'; +import type { AgentConnectOpts } from './base'; +import { parseProxyResponse } from './parse-proxy-response'; + +function debug(...args: unknown[]): void { + logger.log('[https-proxy-agent]', ...args); +} + +type Protocol = T extends `${infer Protocol}:${infer _}` ? Protocol : never; + +type ConnectOptsMap = { + http: Omit; + https: Omit; +}; + +type ConnectOpts = { + [P in keyof ConnectOptsMap]: Protocol extends P ? ConnectOptsMap[P] : never; +}[keyof ConnectOptsMap]; + +export type HttpsProxyAgentOptions = ConnectOpts & + http.AgentOptions & { + headers?: OutgoingHttpHeaders | (() => OutgoingHttpHeaders); + }; + +/** + * The `HttpsProxyAgent` implements an HTTP Agent subclass that connects to + * the specified "HTTP(s) proxy server" in order to proxy HTTPS requests. + * + * Outgoing HTTP requests are first tunneled through the proxy server using the + * `CONNECT` HTTP request method to establish a connection to the proxy server, + * and then the proxy server connects to the destination target and issues the + * HTTP request from the proxy server. + * + * `https:` requests have their socket connection upgraded to TLS once + * the connection to the proxy server has been established. + */ +export class HttpsProxyAgent extends Agent { + static protocols = ['http', 'https'] as const; + + readonly proxy: URL; + proxyHeaders: OutgoingHttpHeaders | (() => OutgoingHttpHeaders); + connectOpts: net.TcpNetConnectOpts & tls.ConnectionOptions; + + constructor(proxy: Uri | URL, opts?: HttpsProxyAgentOptions) { + super(opts); + this.options = {}; + this.proxy = typeof proxy === 'string' ? new URL(proxy) : proxy; + this.proxyHeaders = opts?.headers ?? {}; + debug('Creating new HttpsProxyAgent instance: %o', this.proxy.href); + + // Trim off the brackets from IPv6 addresses + const host = (this.proxy.hostname || this.proxy.host).replace(/^\[|\]$/g, ''); + const port = this.proxy.port ? parseInt(this.proxy.port, 10) : this.proxy.protocol === 'https:' ? 443 : 80; + this.connectOpts = { + // Attempt to negotiate http/1.1 for proxy servers that support http/2 + ALPNProtocols: ['http/1.1'], + ...(opts ? omit(opts, 'headers') : null), + host, + port, + }; + } + + /** + * Called when the node-core HTTP client library is creating a + * new HTTP request. + */ + async connect(req: http.ClientRequest, opts: AgentConnectOpts): Promise { + const { proxy } = this; + + if (!opts.host) { + throw new TypeError('No "host" provided'); + } + + // Create a socket connection to the proxy server. + let socket: net.Socket; + if (proxy.protocol === 'https:') { + debug('Creating `tls.Socket`: %o', this.connectOpts); + const servername = this.connectOpts.servername || this.connectOpts.host; + socket = tls.connect({ + ...this.connectOpts, + servername: servername && net.isIP(servername) ? undefined : servername, + }); + } else { + debug('Creating `net.Socket`: %o', this.connectOpts); + socket = net.connect(this.connectOpts); + } + + const headers: OutgoingHttpHeaders = + typeof this.proxyHeaders === 'function' ? this.proxyHeaders() : { ...this.proxyHeaders }; + const host = net.isIPv6(opts.host) ? `[${opts.host}]` : opts.host; + let payload = `CONNECT ${host}:${opts.port} HTTP/1.1\r\n`; + + // Inject the `Proxy-Authorization` header if necessary. + if (proxy.username || proxy.password) { + const auth = `${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password)}`; + headers['Proxy-Authorization'] = `Basic ${Buffer.from(auth).toString('base64')}`; + } + + headers.Host = `${host}:${opts.port}`; + + if (!headers['Proxy-Connection']) { + headers['Proxy-Connection'] = this.keepAlive ? 'Keep-Alive' : 'close'; + } + for (const name of Object.keys(headers)) { + payload += `${name}: ${headers[name]}\r\n`; + } + + const proxyResponsePromise = parseProxyResponse(socket); + + socket.write(`${payload}\r\n`); + + const { connect, buffered } = await proxyResponsePromise; + req.emit('proxyConnect', connect); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore Not EventEmitter in Node types + this.emit('proxyConnect', connect, req); + + if (connect.statusCode === 200) { + req.once('socket', resume); + + if (opts.secureEndpoint) { + // The proxy is connecting to a TLS server, so upgrade + // this socket connection to a TLS connection. + debug('Upgrading socket connection to TLS'); + const servername = opts.servername || opts.host; + return tls.connect({ + ...omit(opts, 'host', 'path', 'port'), + socket, + servername: net.isIP(servername) ? undefined : servername, + }); + } + + return socket; + } + + // Some other status code that's not 200... need to re-play the HTTP + // header "data" events onto the socket once the HTTP machinery is + // attached so that the node core `http` can parse and handle the + // error status code. + + // Close the original socket, and a new "fake" socket is returned + // instead, so that the proxy doesn't get the HTTP request + // written to it (which may contain `Authorization` headers or other + // sensitive data). + // + // See: https://hackerone.com/reports/541502 + socket.destroy(); + + const fakeSocket = new net.Socket({ writable: false }); + fakeSocket.readable = true; + + // Need to wait for the "socket" event to re-play the "data" events. + req.once('socket', (s: net.Socket) => { + debug('Replaying proxy buffer for failed request'); + assert(s.listenerCount('data') > 0); + + // Replay the "buffered" Buffer onto the fake `socket`, since at + // this point the HTTP module machinery has been hooked up for + // the user. + s.push(buffered); + s.push(null); + }); + + return fakeSocket; + } +} + +function resume(socket: net.Socket | tls.TLSSocket): void { + socket.resume(); +} + +function omit( + obj: T, + ...keys: K +): { + [K2 in Exclude]: T[K2]; +} { + const ret = {} as { + [K in keyof typeof obj]: (typeof obj)[K]; + }; + let key: keyof typeof obj; + for (key in obj) { + if (!keys.includes(key)) { + ret[key] = obj[key]; + } + } + return ret; +} diff --git a/packages/node-experimental/src/proxy/parse-proxy-response.ts b/packages/node-experimental/src/proxy/parse-proxy-response.ts new file mode 100644 index 000000000000..e351945e3c0f --- /dev/null +++ b/packages/node-experimental/src/proxy/parse-proxy-response.ts @@ -0,0 +1,137 @@ +/** + * This code was originally forked from https://github.com/TooTallNate/proxy-agents/tree/b133295fd16f6475578b6b15bd9b4e33ecb0d0b7 + * With the following licence: + * + * (The MIT License) + * + * Copyright (c) 2013 Nathan Rajlich * + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * 'Software'), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions:* + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software.* + * + * THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +/* eslint-disable jsdoc/require-jsdoc */ +import type { IncomingHttpHeaders } from 'http'; +import type { Readable } from 'stream'; +import { logger } from '@sentry/utils'; + +function debug(...args: unknown[]): void { + logger.log('[https-proxy-agent:parse-proxy-response]', ...args); +} + +export interface ConnectResponse { + statusCode: number; + statusText: string; + headers: IncomingHttpHeaders; +} + +export function parseProxyResponse(socket: Readable): Promise<{ connect: ConnectResponse; buffered: Buffer }> { + return new Promise((resolve, reject) => { + // we need to buffer any HTTP traffic that happens with the proxy before we get + // the CONNECT response, so that if the response is anything other than an "200" + // response code, then we can re-play the "data" events on the socket once the + // HTTP parser is hooked up... + let buffersLength = 0; + const buffers: Buffer[] = []; + + function read() { + const b = socket.read(); + if (b) ondata(b); + else socket.once('readable', read); + } + + function cleanup() { + socket.removeListener('end', onend); + socket.removeListener('error', onerror); + socket.removeListener('readable', read); + } + + function onend() { + cleanup(); + debug('onend'); + reject(new Error('Proxy connection ended before receiving CONNECT response')); + } + + function onerror(err: Error) { + cleanup(); + debug('onerror %o', err); + reject(err); + } + + function ondata(b: Buffer) { + buffers.push(b); + buffersLength += b.length; + + const buffered = Buffer.concat(buffers, buffersLength); + const endOfHeaders = buffered.indexOf('\r\n\r\n'); + + if (endOfHeaders === -1) { + // keep buffering + debug('have not received end of HTTP headers yet...'); + read(); + return; + } + + const headerParts = buffered.slice(0, endOfHeaders).toString('ascii').split('\r\n'); + const firstLine = headerParts.shift(); + if (!firstLine) { + socket.destroy(); + return reject(new Error('No header received from proxy CONNECT response')); + } + const firstLineParts = firstLine.split(' '); + const statusCode = +firstLineParts[1]; + const statusText = firstLineParts.slice(2).join(' '); + const headers: IncomingHttpHeaders = {}; + for (const header of headerParts) { + if (!header) continue; + const firstColon = header.indexOf(':'); + if (firstColon === -1) { + socket.destroy(); + return reject(new Error(`Invalid header from proxy CONNECT response: "${header}"`)); + } + const key = header.slice(0, firstColon).toLowerCase(); + const value = header.slice(firstColon + 1).trimStart(); + const current = headers[key]; + if (typeof current === 'string') { + headers[key] = [current, value]; + } else if (Array.isArray(current)) { + current.push(value); + } else { + headers[key] = value; + } + } + debug('got proxy server response: %o %o', firstLine, headers); + cleanup(); + resolve({ + connect: { + statusCode, + statusText, + headers, + }, + buffered, + }); + } + + socket.on('error', onerror); + socket.on('end', onend); + + read(); + }); +} diff --git a/packages/node-experimental/src/sdk/client.ts b/packages/node-experimental/src/sdk/client.ts index 4aa88d121414..6037df31c849 100644 --- a/packages/node-experimental/src/sdk/client.ts +++ b/packages/node-experimental/src/sdk/client.ts @@ -1,19 +1,27 @@ -import { NodeClient, SDK_VERSION } from '@sentry/node'; - +import * as os from 'os'; import type { Tracer } from '@opentelemetry/api'; import { trace } from '@opentelemetry/api'; import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; -import { applySdkMetadata } from '@sentry/core'; +import type { ServerRuntimeClientOptions } from '@sentry/core'; +import { SDK_VERSION, ServerRuntimeClient, applySdkMetadata } from '@sentry/core'; +import type { NodeClientOptions } from '../types'; /** A client for using Sentry with Node & OpenTelemetry. */ -export class NodeExperimentalClient extends NodeClient { +export class NodeClient extends ServerRuntimeClient { public traceProvider: BasicTracerProvider | undefined; private _tracer: Tracer | undefined; - public constructor(options: ConstructorParameters[0]) { - applySdkMetadata(options, 'node-experimental'); + public constructor(options: NodeClientOptions) { + const clientOptions: ServerRuntimeClientOptions = { + ...options, + platform: 'node', + runtime: { name: 'node', version: global.process.version }, + serverName: options.serverName || global.process.env.SENTRY_NAME || os.hostname(), + }; + + applySdkMetadata(clientOptions, 'node-experimental'); - super(options); + super(clientOptions); } /** Get the OTEL tracer. */ diff --git a/packages/node-experimental/src/sdk/init.ts b/packages/node-experimental/src/sdk/init.ts index c190c934776b..78d9fa7ef572 100644 --- a/packages/node-experimental/src/sdk/init.ts +++ b/packages/node-experimental/src/sdk/init.ts @@ -10,7 +10,6 @@ import { defaultStackParser, getDefaultIntegrations as getDefaultNodeIntegrations, getSentryRelease, - makeNodeTransport, spotlightIntegration, } from '@sentry/node'; import { setOpenTelemetryContextAsyncContextStrategy } from '@sentry/opentelemetry'; @@ -27,8 +26,9 @@ import { DEBUG_BUILD } from '../debug-build'; import { getAutoPerformanceIntegrations } from '../integrations/getAutoPerformanceIntegrations'; import { httpIntegration } from '../integrations/http'; import { nativeNodeFetchIntegration } from '../integrations/node-fetch'; -import type { NodeExperimentalClientOptions, NodeExperimentalOptions } from '../types'; -import { NodeExperimentalClient } from './client'; +import { makeNodeTransport } from '../transports'; +import type { NodeClientOptions, NodeOptions } from '../types'; +import { NodeClient } from './client'; import { initOtel } from './initOtel'; const ignoredDefaultIntegrations = ['Http', 'Undici']; @@ -46,7 +46,7 @@ export function getDefaultIntegrations(options: Options): Integration[] { /** * Initialize Sentry for Node. */ -export function init(options: NodeExperimentalOptions | undefined = {}): void { +export function init(options: NodeOptions | undefined = {}): void { const clientOptions = getClientOptions(options); if (clientOptions.debug === true) { @@ -66,7 +66,7 @@ export function init(options: NodeExperimentalOptions | undefined = {}): void { const scope = getCurrentScope(); scope.update(options.initialScope); - const client = new NodeExperimentalClient(clientOptions); + const client = new NodeClient(clientOptions); // The client is on the current scope, from where it generally is inherited getCurrentScope().setClient(client); @@ -98,7 +98,7 @@ export function init(options: NodeExperimentalOptions | undefined = {}): void { initOtel(); } -function getClientOptions(options: NodeExperimentalOptions): NodeExperimentalClientOptions { +function getClientOptions(options: NodeOptions): NodeClientOptions { if (options.defaultIntegrations === undefined) { options.defaultIntegrations = getDefaultIntegrations(options); } @@ -126,7 +126,7 @@ function getClientOptions(options: NodeExperimentalOptions): NodeExperimentalCli tracesSampleRate, }); - const clientOptions: NodeExperimentalClientOptions = { + const clientOptions: NodeClientOptions = { ...baseOptions, ...options, ...overwriteOptions, @@ -141,7 +141,7 @@ function getClientOptions(options: NodeExperimentalOptions): NodeExperimentalCli return clientOptions; } -function getRelease(release: NodeExperimentalOptions['release']): string | undefined { +function getRelease(release: NodeOptions['release']): string | undefined { if (release !== undefined) { return release; } @@ -154,7 +154,7 @@ function getRelease(release: NodeExperimentalOptions['release']): string | undef return undefined; } -function getTracesSampleRate(tracesSampleRate: NodeExperimentalOptions['tracesSampleRate']): number | undefined { +function getTracesSampleRate(tracesSampleRate: NodeOptions['tracesSampleRate']): number | undefined { if (tracesSampleRate !== undefined) { return tracesSampleRate; } diff --git a/packages/node-experimental/src/sdk/initOtel.ts b/packages/node-experimental/src/sdk/initOtel.ts index 1303b59483ff..af6de43f5e9a 100644 --- a/packages/node-experimental/src/sdk/initOtel.ts +++ b/packages/node-experimental/src/sdk/initOtel.ts @@ -8,14 +8,14 @@ import { logger } from '@sentry/utils'; import { DEBUG_BUILD } from '../debug-build'; import { SentryContextManager } from '../otel/contextManager'; -import type { NodeExperimentalClient } from '../types'; import { getClient } from './api'; +import type { NodeClient } from './client'; /** * Initialize OpenTelemetry for Node. */ export function initOtel(): void { - const client = getClient(); + const client = getClient(); if (!client) { DEBUG_BUILD && @@ -43,7 +43,7 @@ export function initOtel(): void { } /** Just exported for tests. */ -export function setupOtel(client: NodeExperimentalClient): BasicTracerProvider { +export function setupOtel(client: NodeClient): BasicTracerProvider { // Create and configure NodeTracerProvider const provider = new BasicTracerProvider({ sampler: new SentrySampler(client), diff --git a/packages/node-experimental/src/sdk/types.ts b/packages/node-experimental/src/sdk/types.ts deleted file mode 100644 index 686e67787916..000000000000 --- a/packages/node-experimental/src/sdk/types.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { - Attachment, - Breadcrumb, - Contexts, - EventProcessor, - Extras, - Primitive, - PropagationContext, - Scope, - SeverityLevel, - User, -} from '@sentry/types'; - -export interface ScopeData { - eventProcessors: EventProcessor[]; - breadcrumbs: Breadcrumb[]; - user: User; - tags: { [key: string]: Primitive }; - extra: Extras; - contexts: Contexts; - attachments: Attachment[]; - propagationContext: PropagationContext; - sdkProcessingMetadata: { [key: string]: unknown }; - fingerprint: string[]; - level?: SeverityLevel; -} - -export interface CurrentScopes { - scope: Scope; - isolationScope: Scope; -} diff --git a/packages/node-experimental/src/transports/http-module.ts b/packages/node-experimental/src/transports/http-module.ts new file mode 100644 index 000000000000..b4dd0492f4fd --- /dev/null +++ b/packages/node-experimental/src/transports/http-module.ts @@ -0,0 +1,29 @@ +import type { ClientRequest, IncomingHttpHeaders, RequestOptions as HTTPRequestOptions } from 'http'; +import type { RequestOptions as HTTPSRequestOptions } from 'https'; +import type { URL } from 'url'; + +export type HTTPModuleRequestOptions = HTTPRequestOptions | HTTPSRequestOptions | string | URL; + +/** + * Cut version of http.IncomingMessage. + * Some transports work in a special Javascript environment where http.IncomingMessage is not available. + */ +export interface HTTPModuleRequestIncomingMessage { + headers: IncomingHttpHeaders; + statusCode?: number; + on(event: 'data' | 'end', listener: () => void): void; + setEncoding(encoding: string): void; +} + +/** + * Internal used interface for typescript. + * @hidden + */ +export interface HTTPModule { + /** + * Request wrapper + * @param options These are {@see TransportOptions} + * @param callback Callback when request is finished + */ + request(options: HTTPModuleRequestOptions, callback?: (res: HTTPModuleRequestIncomingMessage) => void): ClientRequest; +} diff --git a/packages/node-experimental/src/transports/http.ts b/packages/node-experimental/src/transports/http.ts new file mode 100644 index 000000000000..83d8bab5141a --- /dev/null +++ b/packages/node-experimental/src/transports/http.ts @@ -0,0 +1,174 @@ +import * as http from 'http'; +import * as https from 'https'; +import { Readable } from 'stream'; +import { URL } from 'url'; +import { createGzip } from 'zlib'; +import { createTransport } from '@sentry/core'; +import type { + BaseTransportOptions, + Transport, + TransportMakeRequestResponse, + TransportRequest, + TransportRequestExecutor, +} from '@sentry/types'; +import { consoleSandbox } from '@sentry/utils'; +import { HttpsProxyAgent } from '../proxy'; + +import type { HTTPModule } from './http-module'; + +export interface NodeTransportOptions extends BaseTransportOptions { + /** Define custom headers */ + headers?: Record; + /** Set a proxy that should be used for outbound requests. */ + proxy?: string; + /** HTTPS proxy CA certificates */ + caCerts?: string | Buffer | Array; + /** Custom HTTP module. Defaults to the native 'http' and 'https' modules. */ + httpModule?: HTTPModule; + /** Allow overriding connection keepAlive, defaults to false */ + keepAlive?: boolean; +} + +// Estimated maximum size for reasonable standalone event +const GZIP_THRESHOLD = 1024 * 32; + +/** + * Gets a stream from a Uint8Array or string + * Readable.from is ideal but was added in node.js v12.3.0 and v10.17.0 + */ +function streamFromBody(body: Uint8Array | string): Readable { + return new Readable({ + read() { + this.push(body); + this.push(null); + }, + }); +} + +/** + * Creates a Transport that uses native the native 'http' and 'https' modules to send events to Sentry. + */ +export function makeNodeTransport(options: NodeTransportOptions): Transport { + let urlSegments: URL; + + try { + urlSegments = new URL(options.url); + } catch (e) { + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.warn( + '[@sentry/node]: Invalid dsn or tunnel option, will not send any events. The tunnel option must be a full URL when used.', + ); + }); + return createTransport(options, () => Promise.resolve({})); + } + + const isHttps = urlSegments.protocol === 'https:'; + + // Proxy prioritization: http => `options.proxy` | `process.env.http_proxy` + // Proxy prioritization: https => `options.proxy` | `process.env.https_proxy` | `process.env.http_proxy` + const proxy = applyNoProxyOption( + urlSegments, + options.proxy || (isHttps ? process.env.https_proxy : undefined) || process.env.http_proxy, + ); + + const nativeHttpModule = isHttps ? https : http; + const keepAlive = options.keepAlive === undefined ? false : options.keepAlive; + + // TODO(v7): Evaluate if we can set keepAlive to true. This would involve testing for memory leaks in older node + // versions(>= 8) as they had memory leaks when using it: #2555 + const agent = proxy + ? (new HttpsProxyAgent(proxy) as http.Agent) + : new nativeHttpModule.Agent({ keepAlive, maxSockets: 30, timeout: 2000 }); + + const requestExecutor = createRequestExecutor(options, options.httpModule ?? nativeHttpModule, agent); + return createTransport(options, requestExecutor); +} + +/** + * Honors the `no_proxy` env variable with the highest priority to allow for hosts exclusion. + * + * @param transportUrl The URL the transport intends to send events to. + * @param proxy The client configured proxy. + * @returns A proxy the transport should use. + */ +function applyNoProxyOption(transportUrlSegments: URL, proxy: string | undefined): string | undefined { + const { no_proxy } = process.env; + + const urlIsExemptFromProxy = + no_proxy && + no_proxy + .split(',') + .some( + exemption => transportUrlSegments.host.endsWith(exemption) || transportUrlSegments.hostname.endsWith(exemption), + ); + + if (urlIsExemptFromProxy) { + return undefined; + } else { + return proxy; + } +} + +/** + * Creates a RequestExecutor to be used with `createTransport`. + */ +function createRequestExecutor( + options: NodeTransportOptions, + httpModule: HTTPModule, + agent: http.Agent, +): TransportRequestExecutor { + const { hostname, pathname, port, protocol, search } = new URL(options.url); + return function makeRequest(request: TransportRequest): Promise { + return new Promise((resolve, reject) => { + let body = streamFromBody(request.body); + + const headers: Record = { ...options.headers }; + + if (request.body.length > GZIP_THRESHOLD) { + headers['content-encoding'] = 'gzip'; + body = body.pipe(createGzip()); + } + + const req = httpModule.request( + { + method: 'POST', + agent, + headers, + hostname, + path: `${pathname}${search}`, + port, + protocol, + ca: options.caCerts, + }, + res => { + res.on('data', () => { + // Drain socket + }); + + res.on('end', () => { + // Drain socket + }); + + res.setEncoding('utf8'); + + // "Key-value pairs of header names and values. Header names are lower-cased." + // https://nodejs.org/api/http.html#http_message_headers + const retryAfterHeader = res.headers['retry-after'] ?? null; + const rateLimitsHeader = res.headers['x-sentry-rate-limits'] ?? null; + + resolve({ + statusCode: res.statusCode, + headers: { + 'retry-after': retryAfterHeader, + 'x-sentry-rate-limits': Array.isArray(rateLimitsHeader) ? rateLimitsHeader[0] : rateLimitsHeader, + }, + }); + }, + ); + + req.on('error', reject); + body.pipe(req); + }); + }; +} diff --git a/packages/node-experimental/src/transports/index.ts b/packages/node-experimental/src/transports/index.ts new file mode 100644 index 000000000000..ba59ba8878a4 --- /dev/null +++ b/packages/node-experimental/src/transports/index.ts @@ -0,0 +1,3 @@ +export type { NodeTransportOptions } from './http'; + +export { makeNodeTransport } from './http'; diff --git a/packages/node-experimental/src/types.ts b/packages/node-experimental/src/types.ts index 70384ddbd3f8..9ac45c54b6ae 100644 --- a/packages/node-experimental/src/types.ts +++ b/packages/node-experimental/src/types.ts @@ -1,13 +1,88 @@ import type { Span as WriteableSpan } from '@opentelemetry/api'; import type { ReadableSpan, Span } from '@opentelemetry/sdk-trace-base'; -import type { NodeClient, NodeOptions } from '@sentry/node'; -import type { OpenTelemetryClient } from '@sentry/opentelemetry'; +import type { ClientOptions, Options, SamplingContext, Scope, TracePropagationTargets } from '@sentry/types'; -export type NodeExperimentalOptions = NodeOptions; -export type NodeExperimentalClientOptions = ConstructorParameters[0]; +import type { NodeTransportOptions } from './transports'; -export interface NodeExperimentalClient extends NodeClient, OpenTelemetryClient { - getOptions(): NodeExperimentalClientOptions; +export interface BaseNodeOptions { + /** + * List of strings/regex controlling to which outgoing requests + * the SDK will attach tracing headers. + * + * By default the SDK will attach those headers to all outgoing + * requests. If this option is provided, the SDK will match the + * request URL of outgoing requests against the items in this + * array, and only attach tracing headers if a match was found. + * + * @example + * ```js + * Sentry.init({ + * tracePropagationTargets: ['api.site.com'], + * }); + * ``` + */ + tracePropagationTargets?: TracePropagationTargets; + + /** + * Sets profiling sample rate when @sentry/profiling-node is installed + */ + profilesSampleRate?: number; + + /** + * Function to compute profiling sample rate dynamically and filter unwanted profiles. + * + * Profiling is enabled if either this or `profilesSampleRate` is defined. If both are defined, `profilesSampleRate` is + * ignored. + * + * Will automatically be passed a context object of default and optional custom data. See + * {@link Transaction.samplingContext} and {@link Hub.startTransaction}. + * + * @returns A sample rate between 0 and 1 (0 drops the profile, 1 guarantees it will be sent). Returning `true` is + * equivalent to returning 1 and returning `false` is equivalent to returning 0. + */ + profilesSampler?: (samplingContext: SamplingContext) => number | boolean; + + /** Sets an optional server name (device name) */ + serverName?: string; + + /** + * Include local variables with stack traces. + * + * Requires the `LocalVariables` integration. + */ + includeLocalVariables?: boolean; + + /** + * If you use Spotlight by Sentry during development, use + * this option to forward captured Sentry events to Spotlight. + * + * Either set it to true, or provide a specific Spotlight Sidecar URL. + * + * More details: https://spotlightjs.com/ + * + * IMPORTANT: Only set this option to `true` while developing, not in production! + */ + spotlight?: boolean | string; + + /** Callback that is executed when a fatal global error occurs. */ + onFatalError?(this: void, error: Error): void; +} + +/** + * Configuration options for the Sentry Node SDK + * @see @sentry/types Options for more information. + */ +export interface NodeOptions extends Options, BaseNodeOptions {} + +/** + * Configuration options for the Sentry Node SDK Client class + * @see NodeClient for more information. + */ +export interface NodeClientOptions extends ClientOptions, BaseNodeOptions {} + +export interface CurrentScopes { + scope: Scope; + isolationScope: Scope; } /** diff --git a/packages/node-experimental/test/helpers/createSpan.ts b/packages/node-experimental/test/helpers/createSpan.ts deleted file mode 100644 index af96dde1e994..000000000000 --- a/packages/node-experimental/test/helpers/createSpan.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { Context, SpanContext } from '@opentelemetry/api'; -import { SpanKind } from '@opentelemetry/api'; -import type { Tracer } from '@opentelemetry/sdk-trace-base'; -import { SentrySpan } from '@opentelemetry/sdk-trace-base'; -import { uuid4 } from '@sentry/utils'; - -export function createSpan( - name?: string, - { spanId, parentSpanId }: { spanId?: string; parentSpanId?: string } = {}, -): SentrySpan { - const spanProcessor = { - onStart: () => {}, - onEnd: () => {}, - }; - const tracer = { - resource: 'test-resource', - instrumentationLibrary: 'test-instrumentation-library', - getSpanLimits: () => ({}), - getActiveSpanProcessor: () => spanProcessor, - } as unknown as Tracer; - - const spanContext: SpanContext = { - spanId: spanId || uuid4(), - traceId: uuid4(), - traceFlags: 0, - }; - - // eslint-disable-next-line deprecation/deprecation - return new SentrySpan(tracer, {} as Context, name || 'test', spanContext, SpanKind.INTERNAL, parentSpanId); -} diff --git a/packages/node-experimental/test/helpers/getDefaultNodePreviewClientOptions.ts b/packages/node-experimental/test/helpers/getDefaultNodePreviewClientOptions.ts index 00778e78582a..ec42177a1335 100644 --- a/packages/node-experimental/test/helpers/getDefaultNodePreviewClientOptions.ts +++ b/packages/node-experimental/test/helpers/getDefaultNodePreviewClientOptions.ts @@ -1,12 +1,11 @@ import { createTransport } from '@sentry/core'; import { resolvedSyncPromise } from '@sentry/utils'; -import type { NodeExperimentalClientOptions } from '../../src/types'; +import type { NodeClientOptions } from '../../src/types'; -export function getDefaultNodeExperimentalClientOptions( - options: Partial = {}, -): NodeExperimentalClientOptions { +export function getDefaultNodeClientOptions(options: Partial = {}): NodeClientOptions { return { + dsn: 'https://username@domain/123', tracesSampleRate: 1, integrations: [], transport: () => createTransport({ recordDroppedEvent: () => undefined }, _ => resolvedSyncPromise({})), diff --git a/packages/node-experimental/test/helpers/mockSdkInit.ts b/packages/node-experimental/test/helpers/mockSdkInit.ts index 33e452a0a6c9..53b8aa308a9c 100644 --- a/packages/node-experimental/test/helpers/mockSdkInit.ts +++ b/packages/node-experimental/test/helpers/mockSdkInit.ts @@ -3,7 +3,7 @@ import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; import { getCurrentScope, getGlobalScope, getIsolationScope } from '@sentry/core'; import { init } from '../../src/sdk/init'; -import type { NodeExperimentalClientOptions } from '../../src/types'; +import type { NodeClientOptions } from '../../src/types'; const PUBLIC_DSN = 'https://username@domain/123'; @@ -14,7 +14,7 @@ export function resetGlobals(): void { getGlobalScope().clear(); } -export function mockSdkInit(options?: Partial) { +export function mockSdkInit(options?: Partial) { resetGlobals(); init({ dsn: PUBLIC_DSN, defaultIntegrations: false, ...options }); } diff --git a/packages/node-experimental/test/integration/breadcrumbs.test.ts b/packages/node-experimental/test/integration/breadcrumbs.test.ts index db3eef9e52fb..d6741b017764 100644 --- a/packages/node-experimental/test/integration/breadcrumbs.test.ts +++ b/packages/node-experimental/test/integration/breadcrumbs.test.ts @@ -1,8 +1,8 @@ import { addBreadcrumb, captureException, withIsolationScope, withScope } from '@sentry/core'; import { startSpan } from '@sentry/opentelemetry'; import { getClient } from '../../src/sdk/api'; +import type { NodeClient } from '../../src/sdk/client'; -import type { NodeExperimentalClient } from '../../src/types'; import { cleanupOtel, mockSdkInit } from '../helpers/mockSdkInit'; describe('Integration | breadcrumbs', () => { @@ -19,7 +19,7 @@ describe('Integration | breadcrumbs', () => { mockSdkInit({ beforeSend, beforeBreadcrumb }); - const client = getClient() as NodeExperimentalClient; + const client = getClient() as NodeClient; addBreadcrumb({ timestamp: 123456, message: 'test1' }); addBreadcrumb({ timestamp: 123457, message: 'test2', data: { nested: 'yes' } }); @@ -101,7 +101,7 @@ describe('Integration | breadcrumbs', () => { mockSdkInit({ beforeSend, beforeBreadcrumb, beforeSendTransaction, enableTracing: true }); - const client = getClient() as NodeExperimentalClient; + const client = getClient() as NodeClient; const error = new Error('test'); @@ -146,7 +146,7 @@ describe('Integration | breadcrumbs', () => { mockSdkInit({ beforeSend, beforeBreadcrumb, beforeSendTransaction, enableTracing: true }); - const client = getClient() as NodeExperimentalClient; + const client = getClient() as NodeClient; const error = new Error('test'); @@ -198,7 +198,7 @@ describe('Integration | breadcrumbs', () => { mockSdkInit({ beforeSend, beforeBreadcrumb, beforeSendTransaction, enableTracing: true }); - const client = getClient() as NodeExperimentalClient; + const client = getClient() as NodeClient; const error = new Error('test'); @@ -239,7 +239,7 @@ describe('Integration | breadcrumbs', () => { mockSdkInit({ beforeSend, beforeBreadcrumb, beforeSendTransaction, enableTracing: true }); - const client = getClient() as NodeExperimentalClient; + const client = getClient() as NodeClient; const error = new Error('test'); @@ -297,7 +297,7 @@ describe('Integration | breadcrumbs', () => { mockSdkInit({ beforeSend, beforeBreadcrumb, beforeSendTransaction, enableTracing: true }); - const client = getClient() as NodeExperimentalClient; + const client = getClient() as NodeClient; const error = new Error('test'); diff --git a/packages/node-experimental/test/integration/scope.test.ts b/packages/node-experimental/test/integration/scope.test.ts index 3f6c8a441216..6552037c548d 100644 --- a/packages/node-experimental/test/integration/scope.test.ts +++ b/packages/node-experimental/test/integration/scope.test.ts @@ -2,7 +2,7 @@ import { getCurrentScope, setGlobalScope } from '@sentry/core'; import { getClient, getSpanScopes } from '@sentry/opentelemetry'; import * as Sentry from '../../src/'; -import type { NodeExperimentalClient } from '../../src/types'; +import type { NodeClient } from '../../src/sdk/client'; import { cleanupOtel, mockSdkInit, resetGlobals } from '../helpers/mockSdkInit'; describe('Integration | Scope', () => { @@ -20,7 +20,7 @@ describe('Integration | Scope', () => { mockSdkInit({ enableTracing, beforeSend, beforeSendTransaction }); - const client = getClient() as NodeExperimentalClient; + const client = getClient() as NodeClient; const rootScope = getCurrentScope(); @@ -121,7 +121,7 @@ describe('Integration | Scope', () => { mockSdkInit({ enableTracing, beforeSend, beforeSendTransaction }); - const client = getClient() as NodeExperimentalClient; + const client = getClient() as NodeClient; const rootScope = getCurrentScope(); const error1 = new Error('test error 1'); diff --git a/packages/node-experimental/test/integration/transactions.test.ts b/packages/node-experimental/test/integration/transactions.test.ts index f1b89e922641..74af33ab3642 100644 --- a/packages/node-experimental/test/integration/transactions.test.ts +++ b/packages/node-experimental/test/integration/transactions.test.ts @@ -6,7 +6,7 @@ import type { PropagationContext, TransactionEvent } from '@sentry/types'; import { logger } from '@sentry/utils'; import * as Sentry from '../../src'; -import type { NodeExperimentalClient } from '../../src/types'; +import type { NodeClient } from '../../src/sdk/client'; import { cleanupOtel, getProvider, mockSdkInit } from '../helpers/mockSdkInit'; describe('Integration | Transactions', () => { @@ -20,7 +20,7 @@ describe('Integration | Transactions', () => { mockSdkInit({ enableTracing: true, beforeSendTransaction }); - const client = Sentry.getClient(); + const client = Sentry.getClient(); Sentry.addBreadcrumb({ message: 'test breadcrumb 1', timestamp: 123456 }); Sentry.setTag('outer.tag', 'test value'); @@ -343,7 +343,7 @@ describe('Integration | Transactions', () => { mockSdkInit({ enableTracing: true, beforeSendTransaction }); - const client = Sentry.getClient(); + const client = Sentry.getClient(); Sentry.addBreadcrumb({ message: 'test breadcrumb 1', timestamp: 123456 }); @@ -516,7 +516,7 @@ describe('Integration | Transactions', () => { mockSdkInit({ enableTracing: true, beforeSendTransaction }); - const client = getClient() as NodeExperimentalClient; + const client = getClient() as NodeClient; // We simulate the correct context we'd normally get from the SentryPropagator context.with( @@ -634,7 +634,7 @@ describe('Integration | Transactions', () => { mockSdkInit({ enableTracing: true, beforeSendTransaction }); - const client = getClient() as NodeExperimentalClient; + const client = getClient() as NodeClient; const provider = getProvider(); const multiSpanProcessor = provider?.activeSpanProcessor as | (SpanProcessor & { _spanProcessors?: SpanProcessor[] }) diff --git a/packages/node-experimental/test/sdk/client.test.ts b/packages/node-experimental/test/sdk/client.test.ts index b7db215a4cd8..f9e69b0b7233 100644 --- a/packages/node-experimental/test/sdk/client.test.ts +++ b/packages/node-experimental/test/sdk/client.test.ts @@ -1,15 +1,43 @@ +import * as os from 'os'; import { ProxyTracer } from '@opentelemetry/api'; -import { SDK_VERSION } from '@sentry/core'; +import { + SDK_VERSION, + SessionFlusher, + getCurrentScope, + getGlobalScope, + getIsolationScope, + setCurrentClient, + withIsolationScope, +} from '@sentry/core'; +import type { Event, EventHint } from '@sentry/types'; +import type { Scope } from '@sentry/types'; -import { NodeExperimentalClient } from '../../src/sdk/client'; -import { getDefaultNodeExperimentalClientOptions } from '../helpers/getDefaultNodePreviewClientOptions'; +import { setOpenTelemetryContextAsyncContextStrategy } from '@sentry/opentelemetry'; +import { NodeClient } from '../../src/sdk/client'; +import { initOtel } from '../../src/sdk/initOtel'; +import { getDefaultNodeClientOptions } from '../helpers/getDefaultNodePreviewClientOptions'; +import { cleanupOtel } from '../helpers/mockSdkInit'; + +describe('NodeClient', () => { + beforeEach(() => { + getIsolationScope().clear(); + getGlobalScope().clear(); + getCurrentScope().clear(); + getCurrentScope().setClient(undefined); + setOpenTelemetryContextAsyncContextStrategy(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + cleanupOtel(); + }); -describe('NodeExperimentalClient', () => { it('sets correct metadata', () => { - const options = getDefaultNodeExperimentalClientOptions(); - const client = new NodeExperimentalClient(options); + const options = getDefaultNodeClientOptions(); + const client = new NodeClient(options); expect(client.getOptions()).toEqual({ + dsn: expect.any(String), integrations: [], transport: options.transport, stackParser: options.stackParser, @@ -25,7 +53,6 @@ describe('NodeExperimentalClient', () => { version: SDK_VERSION, }, }, - transportOptions: { textEncoder: expect.any(Object) }, platform: 'node', runtime: { name: 'node', version: expect.any(String) }, serverName: expect.any(String), @@ -34,7 +61,7 @@ describe('NodeExperimentalClient', () => { }); it('exposes a tracer', () => { - const client = new NodeExperimentalClient(getDefaultNodeExperimentalClientOptions()); + const client = new NodeClient(getDefaultNodeClientOptions()); const tracer = client.tracer; expect(tracer).toBeDefined(); @@ -45,4 +72,455 @@ describe('NodeExperimentalClient', () => { expect(tracer2).toBe(tracer); }); + + describe('captureException', () => { + test('when autoSessionTracking is enabled, and requestHandler is not used -> requestStatus should not be set', () => { + const options = getDefaultNodeClientOptions({ autoSessionTracking: true, release: '1.4' }); + const client = new NodeClient(options); + setCurrentClient(client); + client.init(); + initOtel(); + + withIsolationScope(isolationScope => { + isolationScope.setRequestSession({ status: 'ok' }); + + client.captureException(new Error('test exception')); + + const requestSession = isolationScope.getRequestSession(); + expect(requestSession!.status).toEqual('ok'); + }); + }); + + test('when autoSessionTracking is disabled -> requestStatus should not be set', () => { + const options = getDefaultNodeClientOptions({ autoSessionTracking: false, release: '1.4' }); + const client = new NodeClient(options); + setCurrentClient(client); + client.init(); + initOtel(); + + // It is required to initialise SessionFlusher to capture Session Aggregates (it is usually initialised + // by the`requestHandler`) + client.initSessionFlusher(); + + withIsolationScope(isolationScope => { + isolationScope.setRequestSession({ status: 'ok' }); + + client.captureException(new Error('test exception')); + + const requestSession = isolationScope.getRequestSession(); + expect(requestSession!.status).toEqual('ok'); + }); + }); + + test('when autoSessionTracking is enabled + requestSession status is Crashed -> requestStatus should not be overridden', () => { + const options = getDefaultNodeClientOptions({ autoSessionTracking: true, release: '1.4' }); + const client = new NodeClient(options); + setCurrentClient(client); + client.init(); + initOtel(); + + // It is required to initialise SessionFlusher to capture Session Aggregates (it is usually initialised + // by the`requestHandler`) + client.initSessionFlusher(); + + withIsolationScope(isolationScope => { + isolationScope.setRequestSession({ status: 'crashed' }); + + client.captureException(new Error('test exception')); + + const requestSession = isolationScope.getRequestSession(); + expect(requestSession!.status).toEqual('crashed'); + }); + }); + + test('when autoSessionTracking is enabled + error occurs within request bounds -> requestStatus should be set to Errored', () => { + const options = getDefaultNodeClientOptions({ autoSessionTracking: true, release: '1.4' }); + const client = new NodeClient(options); + setCurrentClient(client); + client.init(); + initOtel(); + + // It is required to initialise SessionFlusher to capture Session Aggregates (it is usually initialised + // by the`requestHandler`) + client.initSessionFlusher(); + + withIsolationScope(isolationScope => { + isolationScope.setRequestSession({ status: 'ok' }); + + client.captureException(new Error('test exception')); + + const requestSession = isolationScope.getRequestSession(); + expect(requestSession!.status).toEqual('errored'); + }); + }); + + test('when autoSessionTracking is enabled + error occurs outside of request bounds -> requestStatus should not be set to Errored', done => { + const options = getDefaultNodeClientOptions({ autoSessionTracking: true, release: '1.4' }); + const client = new NodeClient(options); + setCurrentClient(client); + client.init(); + initOtel(); + + // It is required to initialise SessionFlusher to capture Session Aggregates (it is usually initialised + // by the`requestHandler`) + client.initSessionFlusher(); + + let isolationScope: Scope; + withIsolationScope(_isolationScope => { + _isolationScope.setRequestSession({ status: 'ok' }); + isolationScope = _isolationScope; + }); + + client.captureException(new Error('test exception')); + + setImmediate(() => { + const requestSession = isolationScope.getRequestSession(); + expect(requestSession).toEqual({ status: 'ok' }); + done(); + }); + }); + }); + + describe('captureEvent()', () => { + test('If autoSessionTracking is disabled, requestSession status should not be set', () => { + const options = getDefaultNodeClientOptions({ autoSessionTracking: false, release: '1.4' }); + const client = new NodeClient(options); + setCurrentClient(client); + client.init(); + initOtel(); + + // It is required to initialise SessionFlusher to capture Session Aggregates (it is usually initialised + // by the`requestHandler`) + client.initSessionFlusher(); + + withIsolationScope(isolationScope => { + isolationScope.setRequestSession({ status: 'ok' }); + client.captureEvent({ message: 'message', exception: { values: [{ type: 'exception type 1' }] } }); + const requestSession = isolationScope.getRequestSession(); + expect(requestSession!.status).toEqual('ok'); + }); + }); + + test('When captureEvent is called with an exception, requestSession status should be set to Errored', () => { + const options = getDefaultNodeClientOptions({ autoSessionTracking: true, release: '2.2' }); + const client = new NodeClient(options); + // It is required to initialise SessionFlusher to capture Session Aggregates (it is usually initialised + // by the`requestHandler`) + client.initSessionFlusher(); + + withIsolationScope(isolationScope => { + isolationScope.setRequestSession({ status: 'ok' }); + + client.captureEvent({ message: 'message', exception: { values: [{ type: 'exception type 1' }] } }); + + const requestSession = isolationScope.getRequestSession(); + expect(requestSession!.status).toEqual('errored'); + }); + }); + + test('When captureEvent is called without an exception, requestSession status should not be set to Errored', () => { + const options = getDefaultNodeClientOptions({ autoSessionTracking: true, release: '2.2' }); + const client = new NodeClient(options); + setCurrentClient(client); + client.init(); + initOtel(); + + // It is required to initialise SessionFlusher to capture Session Aggregates (it is usually initialised + // by the`requestHandler`) + client.initSessionFlusher(); + + withIsolationScope(isolationScope => { + isolationScope.setRequestSession({ status: 'ok' }); + + client.captureEvent({ message: 'message' }); + + const requestSession = isolationScope.getRequestSession(); + expect(requestSession!.status).toEqual('ok'); + }); + }); + + test('When captureEvent is called with an exception but outside of a request, then requestStatus should not be set', () => { + const options = getDefaultNodeClientOptions({ autoSessionTracking: true, release: '2.2' }); + const client = new NodeClient(options); + setCurrentClient(client); + client.init(); + initOtel(); + + // It is required to initialise SessionFlusher to capture Session Aggregates (it is usually initialised + // by the`requestHandler`) + client.initSessionFlusher(); + + withIsolationScope(isolationScope => { + isolationScope.clear(); + client.captureEvent({ message: 'message', exception: { values: [{ type: 'exception type 1' }] } }); + + expect(isolationScope.getRequestSession()).toEqual(undefined); + }); + }); + + test('When captureEvent is called with a transaction, then requestSession status should not be set', () => { + const options = getDefaultNodeClientOptions({ autoSessionTracking: true, release: '1.3' }); + const client = new NodeClient(options); + setCurrentClient(client); + client.init(); + initOtel(); + + // It is required to initialise SessionFlusher to capture Session Aggregates (it is usually initialised + // by the`requestHandler`) + client.initSessionFlusher(); + + withIsolationScope(isolationScope => { + isolationScope.setRequestSession({ status: 'ok' }); + + client.captureEvent({ message: 'message', type: 'transaction' }); + + const requestSession = isolationScope.getRequestSession(); + expect(requestSession!.status).toEqual('ok'); + }); + }); + + test('When captureEvent is called with an exception but requestHandler is not used, then requestSession status should not be set', () => { + const options = getDefaultNodeClientOptions({ autoSessionTracking: true, release: '1.3' }); + const client = new NodeClient(options); + setCurrentClient(client); + client.init(); + initOtel(); + + withIsolationScope(isolationScope => { + isolationScope.setRequestSession({ status: 'ok' }); + + client.captureEvent({ message: 'message', exception: { values: [{ type: 'exception type 1' }] } }); + + const requestSession = isolationScope.getRequestSession(); + expect(requestSession!.status).toEqual('ok'); + }); + }); + }); + + describe('_prepareEvent', () => { + test('adds platform to event', () => { + const options = getDefaultNodeClientOptions({}); + const client = new NodeClient(options); + + const event: Event = {}; + const hint: EventHint = {}; + client['_prepareEvent'](event, hint); + + expect(event.platform).toEqual('node'); + }); + + test('adds runtime context to event', () => { + const options = getDefaultNodeClientOptions({}); + const client = new NodeClient(options); + + const event: Event = {}; + const hint: EventHint = {}; + client['_prepareEvent'](event, hint); + + expect(event.contexts?.runtime).toEqual({ + name: 'node', + version: process.version, + }); + }); + + test('adds server name to event when value passed in options', () => { + const options = getDefaultNodeClientOptions({ serverName: 'foo' }); + const client = new NodeClient(options); + + const event: Event = {}; + const hint: EventHint = {}; + client['_prepareEvent'](event, hint); + + expect(event.server_name).toEqual('foo'); + }); + + test('adds server name to event when value given in env', () => { + const options = getDefaultNodeClientOptions({}); + process.env.SENTRY_NAME = 'foo'; + const client = new NodeClient(options); + + const event: Event = {}; + const hint: EventHint = {}; + client['_prepareEvent'](event, hint); + + expect(event.server_name).toEqual('foo'); + + delete process.env.SENTRY_NAME; + }); + + test('adds hostname as event server name when no value given', () => { + const options = getDefaultNodeClientOptions({}); + const client = new NodeClient(options); + + const event: Event = {}; + const hint: EventHint = {}; + client['_prepareEvent'](event, hint); + + expect(event.server_name).toEqual(os.hostname()); + }); + + test("doesn't clobber existing runtime data", () => { + const options = getDefaultNodeClientOptions({ serverName: 'bar' }); + const client = new NodeClient(options); + + const event: Event = { contexts: { runtime: { name: 'foo', version: '1.2.3' } } }; + const hint: EventHint = {}; + client['_prepareEvent'](event, hint); + + expect(event.contexts?.runtime).toEqual({ name: 'foo', version: '1.2.3' }); + expect(event.contexts?.runtime).not.toEqual({ name: 'node', version: process.version }); + }); + + test("doesn't clobber existing server name", () => { + const options = getDefaultNodeClientOptions({ serverName: 'bar' }); + const client = new NodeClient(options); + + const event: Event = { server_name: 'foo' }; + const hint: EventHint = {}; + client['_prepareEvent'](event, hint); + + expect(event.server_name).toEqual('foo'); + expect(event.server_name).not.toEqual('bar'); + }); + }); + + describe('captureCheckIn', () => { + it('sends a checkIn envelope', () => { + const options = getDefaultNodeClientOptions({ + serverName: 'bar', + release: '1.0.0', + environment: 'dev', + }); + const client = new NodeClient(options); + + // @ts-expect-error accessing private method + const sendEnvelopeSpy = jest.spyOn(client, '_sendEnvelope'); + + const id = client.captureCheckIn( + { monitorSlug: 'foo', status: 'in_progress' }, + { + schedule: { + type: 'crontab', + value: '0 * * * *', + }, + checkinMargin: 2, + maxRuntime: 12333, + timezone: 'Canada/Eastern', + }, + ); + + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + expect(sendEnvelopeSpy).toHaveBeenCalledWith([ + expect.any(Object), + [ + [ + expect.any(Object), + { + check_in_id: id, + monitor_slug: 'foo', + status: 'in_progress', + release: '1.0.0', + environment: 'dev', + monitor_config: { + schedule: { + type: 'crontab', + value: '0 * * * *', + }, + checkin_margin: 2, + max_runtime: 12333, + timezone: 'Canada/Eastern', + }, + }, + ], + ], + ]); + + client.captureCheckIn({ monitorSlug: 'foo', status: 'ok', duration: 1222, checkInId: id }); + + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(2); + expect(sendEnvelopeSpy).toHaveBeenCalledWith([ + expect.any(Object), + [ + [ + expect.any(Object), + { + check_in_id: id, + monitor_slug: 'foo', + duration: 1222, + status: 'ok', + release: '1.0.0', + environment: 'dev', + }, + ], + ], + ]); + }); + + it('sends a checkIn envelope for heartbeat checkIns', () => { + const options = getDefaultNodeClientOptions({ + serverName: 'server', + release: '1.0.0', + environment: 'dev', + }); + const client = new NodeClient(options); + + // @ts-expect-error accessing private method + const sendEnvelopeSpy = jest.spyOn(client, '_sendEnvelope'); + + const id = client.captureCheckIn({ monitorSlug: 'heartbeat-monitor', status: 'ok' }); + + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(1); + expect(sendEnvelopeSpy).toHaveBeenCalledWith([ + expect.any(Object), + [ + [ + expect.any(Object), + { + check_in_id: id, + monitor_slug: 'heartbeat-monitor', + status: 'ok', + release: '1.0.0', + environment: 'dev', + }, + ], + ], + ]); + }); + + it('does not send a checkIn envelope if disabled', () => { + const options = getDefaultNodeClientOptions({ serverName: 'bar', enabled: false }); + const client = new NodeClient(options); + + // @ts-expect-error accessing private method + const sendEnvelopeSpy = jest.spyOn(client, '_sendEnvelope'); + + client.captureCheckIn({ monitorSlug: 'foo', status: 'in_progress' }); + + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(0); + }); + }); +}); + +describe('flush/close', () => { + test('client close function disables _sessionFlusher', async () => { + jest.useRealTimers(); + + const options = getDefaultNodeClientOptions({ + autoSessionTracking: true, + release: '1.1', + }); + const client = new NodeClient(options); + client.initSessionFlusher(); + // Clearing interval is important here to ensure that the flush function later on is called by the `client.close()` + // not due to the interval running every 60s + clearInterval(client['_sessionFlusher']!['_intervalId']); + + const sessionFlusherFlushFunc = jest.spyOn(SessionFlusher.prototype, 'flush'); + + const delay = 1; + await client.close(delay); + + expect(client['_sessionFlusher']!['_isEnabled']).toBeFalsy(); + expect(sessionFlusherFlushFunc).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/node-experimental/test/transports/http.test.ts b/packages/node-experimental/test/transports/http.test.ts new file mode 100644 index 000000000000..c8c82d62faba --- /dev/null +++ b/packages/node-experimental/test/transports/http.test.ts @@ -0,0 +1,416 @@ +import * as http from 'http'; +import { TextEncoder } from 'util'; +import { createGunzip } from 'zlib'; +import { createTransport } from '@sentry/core'; +import type { EventEnvelope, EventItem } from '@sentry/types'; +import { addItemToEnvelope, createAttachmentEnvelopeItem, createEnvelope, serializeEnvelope } from '@sentry/utils'; + +import { makeNodeTransport } from '../../src/transports'; + +const textEncoder = new TextEncoder(); + +jest.mock('@sentry/core', () => { + const actualCore = jest.requireActual('@sentry/core'); + return { + ...actualCore, + createTransport: jest.fn().mockImplementation(actualCore.createTransport), + }; +}); + +import * as httpProxyAgent from '../../src/proxy'; + +const SUCCESS = 200; +const RATE_LIMIT = 429; +const INVALID = 400; +const FAILED = 500; + +interface TestServerOptions { + statusCode: number; + responseHeaders?: Record; +} + +let testServer: http.Server | undefined; + +function setupTestServer( + options: TestServerOptions, + requestInspector?: (req: http.IncomingMessage, body: string, raw: Uint8Array) => void, +) { + testServer = http.createServer((req, res) => { + const chunks: Buffer[] = []; + + const stream = req.headers['content-encoding'] === 'gzip' ? req.pipe(createGunzip({})) : req; + + stream.on('data', data => { + chunks.push(data); + }); + + stream.on('end', () => { + requestInspector?.(req, chunks.join(), Buffer.concat(chunks)); + }); + + res.writeHead(options.statusCode, options.responseHeaders); + res.end(); + + // also terminate socket because keepalive hangs connection a bit + // eslint-disable-next-line deprecation/deprecation + res.connection?.end(); + }); + + testServer.listen(18099); + + return new Promise(resolve => { + testServer?.on('listening', resolve); + }); +} + +const TEST_SERVER_URL = 'http://localhost:18099'; + +const EVENT_ENVELOPE = createEnvelope({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, [ + [{ type: 'event' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }] as EventItem, +]); + +const SERIALIZED_EVENT_ENVELOPE = serializeEnvelope(EVENT_ENVELOPE, textEncoder); + +const ATTACHMENT_ITEM = createAttachmentEnvelopeItem( + { filename: 'empty-file.bin', data: new Uint8Array(50_000) }, + textEncoder, +); +const EVENT_ATTACHMENT_ENVELOPE = addItemToEnvelope(EVENT_ENVELOPE, ATTACHMENT_ITEM); +const SERIALIZED_EVENT_ATTACHMENT_ENVELOPE = serializeEnvelope(EVENT_ATTACHMENT_ENVELOPE, textEncoder) as Uint8Array; + +const defaultOptions = { + url: TEST_SERVER_URL, + recordDroppedEvent: () => undefined, + textEncoder, +}; + +// empty function to keep test output clean +const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + +describe('makeNewHttpTransport()', () => { + afterEach(() => { + jest.clearAllMocks(); + + if (testServer) { + testServer.close(); + } + }); + + describe('.send()', () => { + it('should correctly send envelope to server', async () => { + await setupTestServer({ statusCode: SUCCESS }, (req, body) => { + expect(req.method).toBe('POST'); + expect(body).toBe(SERIALIZED_EVENT_ENVELOPE); + }); + + const transport = makeNodeTransport(defaultOptions); + await transport.send(EVENT_ENVELOPE); + }); + + it('allows overriding keepAlive', async () => { + await setupTestServer({ statusCode: SUCCESS }, req => { + expect(req.headers).toEqual( + expect.objectContaining({ + // node http module lower-cases incoming headers + connection: 'keep-alive', + }), + ); + }); + + const transport = makeNodeTransport({ keepAlive: true, ...defaultOptions }); + await transport.send(EVENT_ENVELOPE); + }); + + it('should correctly send user-provided headers to server', async () => { + await setupTestServer({ statusCode: SUCCESS }, req => { + expect(req.headers).toEqual( + expect.objectContaining({ + // node http module lower-cases incoming headers + 'x-some-custom-header-1': 'value1', + 'x-some-custom-header-2': 'value2', + }), + ); + }); + + const transport = makeNodeTransport({ + ...defaultOptions, + headers: { + 'X-Some-Custom-Header-1': 'value1', + 'X-Some-Custom-Header-2': 'value2', + }, + }); + + await transport.send(EVENT_ENVELOPE); + }); + + it.each([RATE_LIMIT, INVALID, FAILED])( + 'should resolve on bad server response (status %i)', + async serverStatusCode => { + await setupTestServer({ statusCode: serverStatusCode }); + + const transport = makeNodeTransport(defaultOptions); + + await expect(transport.send(EVENT_ENVELOPE)).resolves.toEqual( + expect.objectContaining({ statusCode: serverStatusCode }), + ); + }, + ); + + it('should resolve when server responds with rate limit header and status code 200', async () => { + await setupTestServer({ + statusCode: SUCCESS, + responseHeaders: { + 'Retry-After': '2700', + 'X-Sentry-Rate-Limits': '60::organization, 2700::organization', + }, + }); + + const transport = makeNodeTransport(defaultOptions); + await expect(transport.send(EVENT_ENVELOPE)).resolves.toEqual({ + statusCode: SUCCESS, + headers: { + 'retry-after': '2700', + 'x-sentry-rate-limits': '60::organization, 2700::organization', + }, + }); + }); + }); + + describe('compression', () => { + it('small envelopes should not be compressed', async () => { + await setupTestServer( + { + statusCode: SUCCESS, + responseHeaders: {}, + }, + (req, body) => { + expect(req.headers['content-encoding']).toBeUndefined(); + expect(body).toBe(SERIALIZED_EVENT_ENVELOPE); + }, + ); + + const transport = makeNodeTransport(defaultOptions); + await transport.send(EVENT_ENVELOPE); + }); + + it('large envelopes should be compressed', async () => { + await setupTestServer( + { + statusCode: SUCCESS, + responseHeaders: {}, + }, + (req, _, raw) => { + expect(req.headers['content-encoding']).toEqual('gzip'); + expect(raw.buffer).toStrictEqual(SERIALIZED_EVENT_ATTACHMENT_ENVELOPE.buffer); + }, + ); + + const transport = makeNodeTransport(defaultOptions); + await transport.send(EVENT_ATTACHMENT_ENVELOPE); + }); + }); + + describe('proxy', () => { + const proxyAgentSpy = jest + .spyOn(httpProxyAgent, 'HttpsProxyAgent') + // @ts-expect-error using http agent as https proxy agent + .mockImplementation(() => new http.Agent({ keepAlive: false, maxSockets: 30, timeout: 2000 })); + + it('can be configured through option', () => { + makeNodeTransport({ + ...defaultOptions, + url: 'http://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', + proxy: 'http://example.com', + }); + + expect(proxyAgentSpy).toHaveBeenCalledTimes(1); + expect(proxyAgentSpy).toHaveBeenCalledWith('http://example.com'); + }); + + it('can be configured through env variables option', () => { + process.env.http_proxy = 'http://example.com'; + makeNodeTransport({ + ...defaultOptions, + url: 'http://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', + }); + + expect(proxyAgentSpy).toHaveBeenCalledTimes(1); + expect(proxyAgentSpy).toHaveBeenCalledWith('http://example.com'); + delete process.env.http_proxy; + }); + + it('client options have priority over env variables', () => { + process.env.http_proxy = 'http://foo.com'; + makeNodeTransport({ + ...defaultOptions, + url: 'http://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', + proxy: 'http://bar.com', + }); + + expect(proxyAgentSpy).toHaveBeenCalledTimes(1); + expect(proxyAgentSpy).toHaveBeenCalledWith('http://bar.com'); + delete process.env.http_proxy; + }); + + it('no_proxy allows for skipping specific hosts', () => { + process.env.no_proxy = 'sentry.io'; + makeNodeTransport({ + ...defaultOptions, + url: 'http://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', + proxy: 'http://example.com', + }); + + expect(proxyAgentSpy).not.toHaveBeenCalled(); + + delete process.env.no_proxy; + }); + + it('no_proxy works with a port', () => { + process.env.http_proxy = 'http://example.com:8080'; + process.env.no_proxy = 'sentry.io:8989'; + + makeNodeTransport({ + ...defaultOptions, + url: 'http://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', + }); + + expect(proxyAgentSpy).not.toHaveBeenCalled(); + + delete process.env.no_proxy; + delete process.env.http_proxy; + }); + + it('no_proxy works with multiple comma-separated hosts', () => { + process.env.http_proxy = 'http://example.com:8080'; + process.env.no_proxy = 'example.com,sentry.io,wat.com:1337'; + + makeNodeTransport({ + ...defaultOptions, + url: 'http://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', + }); + + expect(proxyAgentSpy).not.toHaveBeenCalled(); + + delete process.env.no_proxy; + delete process.env.http_proxy; + }); + }); + + describe('should register TransportRequestExecutor that returns the correct object from server response', () => { + it('rate limit', async () => { + await setupTestServer({ + statusCode: RATE_LIMIT, + responseHeaders: {}, + }); + + makeNodeTransport(defaultOptions); + const registeredRequestExecutor = (createTransport as jest.Mock).mock.calls[0][1]; + + const executorResult = registeredRequestExecutor({ + body: serializeEnvelope(EVENT_ENVELOPE, textEncoder), + category: 'error', + }); + + await expect(executorResult).resolves.toEqual( + expect.objectContaining({ + statusCode: RATE_LIMIT, + }), + ); + }); + + it('OK', async () => { + await setupTestServer({ + statusCode: SUCCESS, + }); + + makeNodeTransport(defaultOptions); + const registeredRequestExecutor = (createTransport as jest.Mock).mock.calls[0][1]; + + const executorResult = registeredRequestExecutor({ + body: serializeEnvelope(EVENT_ENVELOPE, textEncoder), + category: 'error', + }); + + await expect(executorResult).resolves.toEqual( + expect.objectContaining({ + statusCode: SUCCESS, + headers: { + 'retry-after': null, + 'x-sentry-rate-limits': null, + }, + }), + ); + }); + + it('OK with rate-limit headers', async () => { + await setupTestServer({ + statusCode: SUCCESS, + responseHeaders: { + 'Retry-After': '2700', + 'X-Sentry-Rate-Limits': '60::organization, 2700::organization', + }, + }); + + makeNodeTransport(defaultOptions); + const registeredRequestExecutor = (createTransport as jest.Mock).mock.calls[0][1]; + + const executorResult = registeredRequestExecutor({ + body: serializeEnvelope(EVENT_ENVELOPE, textEncoder), + category: 'error', + }); + + await expect(executorResult).resolves.toEqual( + expect.objectContaining({ + statusCode: SUCCESS, + headers: { + 'retry-after': '2700', + 'x-sentry-rate-limits': '60::organization, 2700::organization', + }, + }), + ); + }); + + it('NOK with rate-limit headers', async () => { + await setupTestServer({ + statusCode: RATE_LIMIT, + responseHeaders: { + 'Retry-After': '2700', + 'X-Sentry-Rate-Limits': '60::organization, 2700::organization', + }, + }); + + makeNodeTransport(defaultOptions); + const registeredRequestExecutor = (createTransport as jest.Mock).mock.calls[0][1]; + + const executorResult = registeredRequestExecutor({ + body: serializeEnvelope(EVENT_ENVELOPE, textEncoder), + category: 'error', + }); + + await expect(executorResult).resolves.toEqual( + expect.objectContaining({ + statusCode: RATE_LIMIT, + headers: { + 'retry-after': '2700', + 'x-sentry-rate-limits': '60::organization, 2700::organization', + }, + }), + ); + }); + }); + + it('should create a noop transport if an invalid url is passed', async () => { + const requestSpy = jest.spyOn(http, 'request'); + const transport = makeNodeTransport({ ...defaultOptions, url: 'foo' }); + await transport.send(EVENT_ENVELOPE); + expect(requestSpy).not.toHaveBeenCalled(); + }); + + it('should warn if an invalid url is passed', async () => { + const transport = makeNodeTransport({ ...defaultOptions, url: 'invalid url' }); + await transport.send(EVENT_ENVELOPE); + expect(consoleWarnSpy).toHaveBeenCalledWith( + '[@sentry/node]: Invalid dsn or tunnel option, will not send any events. The tunnel option must be a full URL when used.', + ); + }); +}); diff --git a/packages/node-experimental/test/transports/https.test.ts b/packages/node-experimental/test/transports/https.test.ts new file mode 100644 index 000000000000..40bed042eca5 --- /dev/null +++ b/packages/node-experimental/test/transports/https.test.ts @@ -0,0 +1,389 @@ +import * as http from 'http'; +import * as https from 'https'; +import { TextEncoder } from 'util'; +import { createTransport } from '@sentry/core'; +import type { EventEnvelope, EventItem } from '@sentry/types'; +import { createEnvelope, serializeEnvelope } from '@sentry/utils'; + +import { makeNodeTransport } from '../../src/transports'; +import type { HTTPModule, HTTPModuleRequestIncomingMessage } from '../../src/transports/http-module'; +import testServerCerts from './test-server-certs'; + +const textEncoder = new TextEncoder(); + +jest.mock('@sentry/core', () => { + const actualCore = jest.requireActual('@sentry/core'); + return { + ...actualCore, + createTransport: jest.fn().mockImplementation(actualCore.createTransport), + }; +}); + +import * as httpProxyAgent from '../../src/proxy'; + +const SUCCESS = 200; +const RATE_LIMIT = 429; +const INVALID = 400; +const FAILED = 500; + +interface TestServerOptions { + statusCode: number; + responseHeaders?: Record; +} + +let testServer: http.Server | undefined; + +function setupTestServer( + options: TestServerOptions, + requestInspector?: (req: http.IncomingMessage, body: string) => void, +) { + testServer = https.createServer(testServerCerts, (req, res) => { + let body = ''; + + req.on('data', data => { + body += data; + }); + + req.on('end', () => { + requestInspector?.(req, body); + }); + + res.writeHead(options.statusCode, options.responseHeaders); + res.end(); + + // also terminate socket because keepalive hangs connection a bit + // eslint-disable-next-line deprecation/deprecation + res.connection?.end(); + }); + + testServer.listen(8099); + + return new Promise(resolve => { + testServer?.on('listening', resolve); + }); +} + +const TEST_SERVER_URL = 'https://localhost:8099'; + +const EVENT_ENVELOPE = createEnvelope({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, [ + [{ type: 'event' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }] as EventItem, +]); + +const SERIALIZED_EVENT_ENVELOPE = serializeEnvelope(EVENT_ENVELOPE, textEncoder); + +const unsafeHttpsModule: HTTPModule = { + request: jest + .fn() + .mockImplementation((options: https.RequestOptions, callback?: (res: HTTPModuleRequestIncomingMessage) => void) => { + return https.request({ ...options, rejectUnauthorized: false }, callback); + }), +}; + +const defaultOptions = { + httpModule: unsafeHttpsModule, + url: TEST_SERVER_URL, + recordDroppedEvent: () => undefined, // noop + textEncoder, +}; + +describe('makeNewHttpsTransport()', () => { + afterEach(() => { + jest.clearAllMocks(); + + if (testServer) { + testServer.close(); + } + }); + + describe('.send()', () => { + it('should correctly send envelope to server', async () => { + await setupTestServer({ statusCode: SUCCESS }, (req, body) => { + expect(req.method).toBe('POST'); + expect(body).toBe(SERIALIZED_EVENT_ENVELOPE); + }); + + const transport = makeNodeTransport(defaultOptions); + await transport.send(EVENT_ENVELOPE); + }); + + it('should correctly send user-provided headers to server', async () => { + await setupTestServer({ statusCode: SUCCESS }, req => { + expect(req.headers).toEqual( + expect.objectContaining({ + // node http module lower-cases incoming headers + 'x-some-custom-header-1': 'value1', + 'x-some-custom-header-2': 'value2', + }), + ); + }); + + const transport = makeNodeTransport({ + ...defaultOptions, + headers: { + 'X-Some-Custom-Header-1': 'value1', + 'X-Some-Custom-Header-2': 'value2', + }, + }); + + await transport.send(EVENT_ENVELOPE); + }); + + it.each([RATE_LIMIT, INVALID, FAILED])( + 'should resolve on bad server response (status %i)', + async serverStatusCode => { + await setupTestServer({ statusCode: serverStatusCode }); + + const transport = makeNodeTransport(defaultOptions); + expect(() => { + expect(transport.send(EVENT_ENVELOPE)); + }).not.toThrow(); + }, + ); + + it('should resolve when server responds with rate limit header and status code 200', async () => { + await setupTestServer({ + statusCode: SUCCESS, + responseHeaders: { + 'Retry-After': '2700', + 'X-Sentry-Rate-Limits': '60::organization, 2700::organization', + }, + }); + + const transport = makeNodeTransport(defaultOptions); + await expect(transport.send(EVENT_ENVELOPE)).resolves.toEqual({ + statusCode: SUCCESS, + headers: { + 'retry-after': '2700', + 'x-sentry-rate-limits': '60::organization, 2700::organization', + }, + }); + }); + + it('should use `caCerts` option', async () => { + await setupTestServer({ statusCode: SUCCESS }); + + const transport = makeNodeTransport({ + ...defaultOptions, + httpModule: unsafeHttpsModule, + url: TEST_SERVER_URL, + caCerts: 'some cert', + }); + + await transport.send(EVENT_ENVELOPE); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(unsafeHttpsModule.request).toHaveBeenCalledWith( + expect.objectContaining({ + ca: 'some cert', + }), + expect.anything(), + ); + }); + }); + + describe('proxy', () => { + const proxyAgentSpy = jest + .spyOn(httpProxyAgent, 'HttpsProxyAgent') + // @ts-expect-error using http agent as https proxy agent + .mockImplementation(() => new http.Agent({ keepAlive: false, maxSockets: 30, timeout: 2000 })); + + it('can be configured through option', () => { + makeNodeTransport({ + ...defaultOptions, + httpModule: unsafeHttpsModule, + url: 'https://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', + proxy: 'https://example.com', + }); + + expect(proxyAgentSpy).toHaveBeenCalledTimes(1); + expect(proxyAgentSpy).toHaveBeenCalledWith('https://example.com'); + }); + + it('can be configured through env variables option (http)', () => { + process.env.http_proxy = 'https://example.com'; + makeNodeTransport({ + ...defaultOptions, + httpModule: unsafeHttpsModule, + url: 'https://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', + }); + + expect(proxyAgentSpy).toHaveBeenCalledTimes(1); + expect(proxyAgentSpy).toHaveBeenCalledWith('https://example.com'); + delete process.env.http_proxy; + }); + + it('can be configured through env variables option (https)', () => { + process.env.https_proxy = 'https://example.com'; + makeNodeTransport({ + ...defaultOptions, + httpModule: unsafeHttpsModule, + url: 'https://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', + }); + + expect(proxyAgentSpy).toHaveBeenCalledTimes(1); + expect(proxyAgentSpy).toHaveBeenCalledWith('https://example.com'); + delete process.env.https_proxy; + }); + + it('client options have priority over env variables', () => { + process.env.https_proxy = 'https://foo.com'; + makeNodeTransport({ + ...defaultOptions, + httpModule: unsafeHttpsModule, + url: 'https://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', + proxy: 'https://bar.com', + }); + + expect(proxyAgentSpy).toHaveBeenCalledTimes(1); + expect(proxyAgentSpy).toHaveBeenCalledWith('https://bar.com'); + delete process.env.https_proxy; + }); + + it('no_proxy allows for skipping specific hosts', () => { + process.env.no_proxy = 'sentry.io'; + makeNodeTransport({ + ...defaultOptions, + httpModule: unsafeHttpsModule, + url: 'https://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', + proxy: 'https://example.com', + }); + + expect(proxyAgentSpy).not.toHaveBeenCalled(); + + delete process.env.no_proxy; + }); + + it('no_proxy works with a port', () => { + process.env.http_proxy = 'https://example.com:8080'; + process.env.no_proxy = 'sentry.io:8989'; + + makeNodeTransport({ + ...defaultOptions, + httpModule: unsafeHttpsModule, + url: 'https://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', + }); + + expect(proxyAgentSpy).not.toHaveBeenCalled(); + + delete process.env.no_proxy; + delete process.env.http_proxy; + }); + + it('no_proxy works with multiple comma-separated hosts', () => { + process.env.http_proxy = 'https://example.com:8080'; + process.env.no_proxy = 'example.com,sentry.io,wat.com:1337'; + + makeNodeTransport({ + ...defaultOptions, + httpModule: unsafeHttpsModule, + url: 'https://9e9fd4523d784609a5fc0ebb1080592f@sentry.io:8989/mysubpath/50622', + }); + + expect(proxyAgentSpy).not.toHaveBeenCalled(); + + delete process.env.no_proxy; + delete process.env.http_proxy; + }); + }); + + it('should register TransportRequestExecutor that returns the correct object from server response (rate limit)', async () => { + await setupTestServer({ + statusCode: RATE_LIMIT, + responseHeaders: {}, + }); + + makeNodeTransport(defaultOptions); + const registeredRequestExecutor = (createTransport as jest.Mock).mock.calls[0][1]; + + const executorResult = registeredRequestExecutor({ + body: serializeEnvelope(EVENT_ENVELOPE, textEncoder), + category: 'error', + }); + + await expect(executorResult).resolves.toEqual( + expect.objectContaining({ + statusCode: RATE_LIMIT, + }), + ); + }); + + it('should register TransportRequestExecutor that returns the correct object from server response (OK)', async () => { + await setupTestServer({ + statusCode: SUCCESS, + }); + + makeNodeTransport(defaultOptions); + const registeredRequestExecutor = (createTransport as jest.Mock).mock.calls[0][1]; + + const executorResult = registeredRequestExecutor({ + body: serializeEnvelope(EVENT_ENVELOPE, textEncoder), + category: 'error', + }); + + await expect(executorResult).resolves.toEqual( + expect.objectContaining({ + statusCode: SUCCESS, + headers: { + 'retry-after': null, + 'x-sentry-rate-limits': null, + }, + }), + ); + }); + + it('should register TransportRequestExecutor that returns the correct object from server response (OK with rate-limit headers)', async () => { + await setupTestServer({ + statusCode: SUCCESS, + responseHeaders: { + 'Retry-After': '2700', + 'X-Sentry-Rate-Limits': '60::organization, 2700::organization', + }, + }); + + makeNodeTransport(defaultOptions); + const registeredRequestExecutor = (createTransport as jest.Mock).mock.calls[0][1]; + + const executorResult = registeredRequestExecutor({ + body: serializeEnvelope(EVENT_ENVELOPE, textEncoder), + category: 'error', + }); + + await expect(executorResult).resolves.toEqual( + expect.objectContaining({ + statusCode: SUCCESS, + headers: { + 'retry-after': '2700', + 'x-sentry-rate-limits': '60::organization, 2700::organization', + }, + }), + ); + }); + + it('should register TransportRequestExecutor that returns the correct object from server response (NOK with rate-limit headers)', async () => { + await setupTestServer({ + statusCode: RATE_LIMIT, + responseHeaders: { + 'Retry-After': '2700', + 'X-Sentry-Rate-Limits': '60::organization, 2700::organization', + }, + }); + + makeNodeTransport(defaultOptions); + const registeredRequestExecutor = (createTransport as jest.Mock).mock.calls[0][1]; + + const executorResult = registeredRequestExecutor({ + body: serializeEnvelope(EVENT_ENVELOPE, textEncoder), + category: 'error', + }); + + await expect(executorResult).resolves.toEqual( + expect.objectContaining({ + statusCode: RATE_LIMIT, + headers: { + 'retry-after': '2700', + 'x-sentry-rate-limits': '60::organization, 2700::organization', + }, + }), + ); + }); +}); diff --git a/packages/node-experimental/test/transports/test-server-certs.ts b/packages/node-experimental/test/transports/test-server-certs.ts new file mode 100644 index 000000000000..a5ce436c4234 --- /dev/null +++ b/packages/node-experimental/test/transports/test-server-certs.ts @@ -0,0 +1,48 @@ +export default { + key: `-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAuMunjXC2tu2d4x8vKuPQbHwPjYG6pVvAUs7wzpDnMEGo3o2A +bZpL7vUAkQWZ86M84rX9b65cVvT35uqM9uxnJKQhSdGARxEcrz9yxjc9RaIO9xM4 +6WdFd6pcVHW9MF6njnc19jyIoSGXRADJjreNZHyMobAHyL2ZbFiptknUWFW3YT4t +q9bQD5yfhZ94fRt1IbdBAn5Bmz6x61BYudWU2KA3G1akPUmzj0OwZwaIrnGbfLUH +M5F50dNUYfCdmxtE8YRBPyWwcg+KOWa/P8C84p1UQ+/0GHNqUTa4wXBgKeUXNjth +AhV/4JgDDdec+/W0Z1UdEqxZvKfAYnjveFpxEwIDAQABAoIBADLsjEPB59gJKxVH +pqvfE7SRi4enVFP1MM6hEGMcM1ls/qg1vkp11q8G/Rz5ui8VsNWY6To5hmDAKQCN +akMxaksCn9nDzeHHqWvxxCMzXcMuoYkc1vYa613KqJ7twzDtJKdx2oD8tXoR06l9 +vg2CL4idefOkmsCK3xioZjxBpC6jF6ybvlY241MGhaAGRHmP6ik1uFJ+6Y8smh6R +AQKO0u0oQPy6bka9F6DTP6BMUeZ+OA/oOrrb5FxTHu8AHcyCSk2wHnCkB9EF/Ou2 +xSWrnu0O0/0Px6OO9oEsNSq2/fKNV9iuEU8LeAoDVm4ysyMrPce2c4ZsB4U244bj +yQpQZ6ECgYEA9KwA7Lmyf+eeZHxEM4MNSqyeXBtSKu4Zyk0RRY1j69ConjHKet3Q +ylVedXQ0/FJAHHKEm4zFGZtnaaxrzCIcQSKJBCoaA+cN44MM3D1nKmHjgPy8R/yE +BNgIVwJB1MmVSGa+NYnQgUomcCIEr/guNMIxV7p2iybqoxaEHKLfGFUCgYEAwVn1 +8LARsZihLUdxxbAc9+v/pBeMTrkTw1eN1ki9VWYoRam2MLozehEzabt677cU4h7+ +bjdKCKo1x2liY9zmbIiVHssv9Jf3E9XhcajsXB42m1+kjUYVPh8o9lDXcatV9EKt +DZK8wfRY9boyDKB2zRyo6bvIEK3qWbas31W3a8cCgYA6w0TFliPkzEAiaiYHKSZ8 +FNFD1dv6K41OJQxM5BRngom81MCImdWXgsFY/DvtjeOP8YEfysNbzxMbMioBsP+Q +NTcrJOFypn+TcNoZ2zV33GLDi++8ak1azHfUTdp5vKB57xMn0J2fL6vjqoftq3GN +gkZPh50I9qPL35CDQCrMsQKBgC6tFfc1uf/Cld5FagzMOCINodguKxvyB/hXUZFS +XAqar8wpbScUPEsSjfPPY50s+GiiDM/0nvW6iWMLaMos0J+Q1VbqvDfy2525O0Ri +ADU4wfv+Oc41BfnKMexMlcYGE6j006v8KX81Cqi/e0ebETLw4UITp/eG1JU1yUPd +AHuPAoGBAL25v4/onoH0FBLdEwb2BAENxc+0g4In1T+83jfHbfD0gOF3XTbgH4FF +MduIG8qBoZC5whiZ3qH7YJK7sydaM1bDwiesqIik+gEUE65T7S2ZF84y5GC5JjTf +z6v6i+DMCIJXDY5/gjzOED6UllV2Jrn2pDoV++zVyR6KAwXpCmK6 +-----END RSA PRIVATE KEY-----`, + cert: `-----BEGIN CERTIFICATE----- +MIIDETCCAfkCFCMI53aBdS2kWTrw39Kkv93ErG3iMA0GCSqGSIb3DQEBCwUAMEUx +CzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRl +cm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMjIwMzI4MDgzODQwWhcNNDkwODEyMDgz +ODQwWjBFMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UE +CgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOC +AQ8AMIIBCgKCAQEAuMunjXC2tu2d4x8vKuPQbHwPjYG6pVvAUs7wzpDnMEGo3o2A +bZpL7vUAkQWZ86M84rX9b65cVvT35uqM9uxnJKQhSdGARxEcrz9yxjc9RaIO9xM4 +6WdFd6pcVHW9MF6njnc19jyIoSGXRADJjreNZHyMobAHyL2ZbFiptknUWFW3YT4t +q9bQD5yfhZ94fRt1IbdBAn5Bmz6x61BYudWU2KA3G1akPUmzj0OwZwaIrnGbfLUH +M5F50dNUYfCdmxtE8YRBPyWwcg+KOWa/P8C84p1UQ+/0GHNqUTa4wXBgKeUXNjth +AhV/4JgDDdec+/W0Z1UdEqxZvKfAYnjveFpxEwIDAQABMA0GCSqGSIb3DQEBCwUA +A4IBAQBh4BKiByhyvAc5uHj5bkSqspY2xZWW8xiEGaCaQWDMlyjP9mVVWFHfE3XL +lzsJdZVnHDZUliuA5L+qTEpLJ5GmgDWqnKp3HdhtkL16mPbPyJLPY0X+m7wvoZRt +RwLfFCx1E13m0ktYWWgmSCnBl+rI7pyagDhZ2feyxsMrecCazyG/llFBuyWSOnIi +OHxjdHV7be5c8uOOp1iNB9j++LW1pRVrSCWOKRLcsUBal73FW+UvhM5+1If/F9pF +GNQrMhVRA8aHD0JAu3tpjYRKRuOpAbbqtiAUSbDPsJBQy/K9no2K83G7+AV+aGai +HXfQqFFJS6xGKU79azH51wLVEGXq +-----END CERTIFICATE-----`, +}; diff --git a/yarn.lock b/yarn.lock index e85098e415ac..d6265ed91967 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6801,6 +6801,11 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.38.tgz#f8bb07c371ccb1903f3752872c89f44006132947" integrity sha512-5jY9RhV7c0Z4Jy09G+NIDTsCZ5G0L5n+Z+p+Y7t5VJHM30bgwzSjVtlcBxqAj+6L/swIlvtOSzr8rBk/aNyV2g== +"@types/node@14.18.63": + version "14.18.63" + resolved "https://registry.yarnpkg.com/@types/node/-/node-14.18.63.tgz#1788fa8da838dbb5f9ea994b834278205db6ca2b" + integrity sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ== + "@types/node@16.18.70": version "16.18.70" resolved "https://registry.yarnpkg.com/@types/node/-/node-16.18.70.tgz#d4c819be1e9f8b69a794d6f2fd929d9ff76f6d4b"