From bd0e035d197158526fc1f76cd45b93594dc41fce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20Og=C3=B3rek?= Date: Thu, 18 Feb 2021 13:35:46 +0100 Subject: [PATCH] [v7] Remove API class, move Dsn to core and simplify it greatly --- packages/browser/src/client.ts | 6 +- packages/browser/src/exports.ts | 4 +- packages/browser/src/helpers.ts | 36 +---- packages/browser/src/sdk.ts | 4 +- packages/browser/src/transports/base.ts | 14 +- packages/browser/src/transports/fetch.ts | 4 +- packages/browser/src/transports/xhr.ts | 4 +- packages/core/src/api.ts | 151 ------------------ packages/core/src/baseclient.ts | 2 +- packages/core/src/dsn.ts | 139 +++++++++++++++++ packages/core/src/index.ts | 2 +- packages/core/src/request.ts | 120 ++++++-------- packages/core/test/lib/api.test.ts | 73 --------- packages/core/test/lib/dsn.test.ts | 143 +++++++++++++++++ packages/utils/src/dsn.ts | 123 --------------- packages/utils/src/index.ts | 1 - packages/utils/src/object.ts | 12 -- packages/utils/test/dsn.test.ts | 191 ----------------------- 18 files changed, 353 insertions(+), 676 deletions(-) delete mode 100644 packages/core/src/api.ts create mode 100644 packages/core/src/dsn.ts delete mode 100644 packages/core/test/lib/api.test.ts create mode 100644 packages/core/test/lib/dsn.test.ts delete mode 100644 packages/utils/src/dsn.ts delete mode 100644 packages/utils/test/dsn.test.ts diff --git a/packages/browser/src/client.ts b/packages/browser/src/client.ts index aeb1539e2a7d..f60312c7a5d7 100644 --- a/packages/browser/src/client.ts +++ b/packages/browser/src/client.ts @@ -1,9 +1,9 @@ -import { BaseClient, Scope, SDK_VERSION } from '@sentry/core'; +import { BaseClient, ReportDialogOptions, Scope, SDK_VERSION } from '@sentry/core'; import { Event, EventHint } from '@sentry/types'; import { getGlobalObject, logger } from '@sentry/utils'; import { BrowserBackend, BrowserOptions } from './backend'; -import { injectReportDialog, ReportDialogOptions } from './helpers'; +import { injectReportDialog } from './helpers'; import { Breadcrumbs } from './integrations'; /** @@ -53,7 +53,7 @@ export class BrowserClient extends BaseClient { injectReportDialog({ ...options, - dsn: options.dsn || this.getDsn(), + dsn: options.dsn || this.getDsn()?.toString(), }); } diff --git a/packages/browser/src/exports.ts b/packages/browser/src/exports.ts index cbe5c0c4f262..a0c9e2425833 100644 --- a/packages/browser/src/exports.ts +++ b/packages/browser/src/exports.ts @@ -24,8 +24,10 @@ export { configureScope, getHubFromCarrier, getCurrentHub, + getReportDialogEndpoint, Hub, makeMain, + ReportDialogOptions, Scope, startTransaction, SDK_VERSION, @@ -40,7 +42,7 @@ export { export { BrowserOptions } from './backend'; export { BrowserClient } from './client'; -export { injectReportDialog, ReportDialogOptions } from './helpers'; +export { injectReportDialog } from './helpers'; export { eventFromException, eventFromMessage } from './eventbuilder'; export { defaultIntegrations, forceLoad, init, lastEventId, onLoad, showReportDialog, flush, close, wrap } from './sdk'; export { SDK_NAME } from './version'; diff --git a/packages/browser/src/helpers.ts b/packages/browser/src/helpers.ts index 3b50fb4a2dad..65360651f378 100644 --- a/packages/browser/src/helpers.ts +++ b/packages/browser/src/helpers.ts @@ -1,5 +1,5 @@ -import { API, captureException, withScope } from '@sentry/core'; -import { DsnLike, Event as SentryEvent, Mechanism, Scope, WrappedFunction } from '@sentry/types'; +import { captureException, Dsn, getReportDialogEndpoint, ReportDialogOptions, withScope } from '@sentry/core'; +import { Event as SentryEvent, Mechanism, Scope, WrappedFunction } from '@sentry/types'; import { addExceptionMechanism, addExceptionTypeValue, logger } from '@sentry/utils'; let ignoreOnError: number = 0; @@ -160,39 +160,11 @@ export function wrap( return sentryWrapped; } -/** - * All properties the report dialog supports - */ -export interface ReportDialogOptions { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - [key: string]: any; - eventId?: string; - dsn?: DsnLike; - user?: { - email?: string; - name?: string; - }; - lang?: string; - title?: string; - subtitle?: string; - subtitle2?: string; - labelName?: string; - labelEmail?: string; - labelComments?: string; - labelClose?: string; - labelSubmit?: string; - errorGeneric?: string; - errorFormEntry?: string; - successMessage?: string; - /** Callback after reportDialog showed up */ - onLoad?(): void; -} - /** * Injects the Report Dialog script * @hidden */ -export function injectReportDialog(options: ReportDialogOptions = {}): void { +export function injectReportDialog(options: ReportDialogOptions & { onLoad?(): void } = {}): void { if (!options.eventId) { logger.error(`Missing eventId option in showReportDialog call`); return; @@ -204,7 +176,7 @@ export function injectReportDialog(options: ReportDialogOptions = {}): void { const script = document.createElement('script'); script.async = true; - script.src = new API(options.dsn).getReportDialogEndpoint(options); + script.src = getReportDialogEndpoint(new Dsn(options.dsn)); if (options.onLoad) { // eslint-disable-next-line @typescript-eslint/unbound-method diff --git a/packages/browser/src/sdk.ts b/packages/browser/src/sdk.ts index 6249ef9b97ed..b794ec70ada6 100644 --- a/packages/browser/src/sdk.ts +++ b/packages/browser/src/sdk.ts @@ -1,9 +1,9 @@ -import { getCurrentHub, initAndBind, Integrations as CoreIntegrations } from '@sentry/core'; +import { getCurrentHub, initAndBind, Integrations as CoreIntegrations, ReportDialogOptions } from '@sentry/core'; import { addInstrumentationHandler, getGlobalObject, logger, SyncPromise } from '@sentry/utils'; import { BrowserOptions } from './backend'; import { BrowserClient } from './client'; -import { ReportDialogOptions, wrap as internalWrap } from './helpers'; +import { wrap as internalWrap } from './helpers'; import { Breadcrumbs, GlobalHandlers, LinkedErrors, TryCatch, UserAgent } from './integrations'; export const defaultIntegrations = [ diff --git a/packages/browser/src/transports/base.ts b/packages/browser/src/transports/base.ts index 09eb8b058bfd..e4b402bea29e 100644 --- a/packages/browser/src/transports/base.ts +++ b/packages/browser/src/transports/base.ts @@ -1,4 +1,3 @@ -import { API } from '@sentry/core'; import { Event, Response as SentryResponse, @@ -8,16 +7,11 @@ import { TransportOptions, } from '@sentry/types'; import { logger, parseRetryAfterHeader, PromiseBuffer, SentryError } from '@sentry/utils'; +import { Dsn } from '@sentry/core'; /** Base Transport class implementation */ export abstract class BaseTransport implements Transport { - /** - * @deprecated - */ - public url: string; - - /** Helper to get Sentry API endpoints. */ - protected readonly _api: API; + protected readonly _dsn: Dsn; /** A simple buffer holding all requests. */ protected readonly _buffer: PromiseBuffer = new PromiseBuffer(30); @@ -26,9 +20,7 @@ export abstract class BaseTransport implements Transport { protected readonly _rateLimits: Record = {}; public constructor(public options: TransportOptions) { - this._api = new API(options.dsn, options._metadata); - // eslint-disable-next-line deprecation/deprecation - this.url = this._api.getStoreEndpointWithUrlEncodedAuth(); + this._dsn = new Dsn(options.dsn as string); } /** diff --git a/packages/browser/src/transports/fetch.ts b/packages/browser/src/transports/fetch.ts index f0bd608eeb43..8636db3d05f4 100644 --- a/packages/browser/src/transports/fetch.ts +++ b/packages/browser/src/transports/fetch.ts @@ -90,14 +90,14 @@ export class FetchTransport extends BaseTransport { * @inheritDoc */ public sendEvent(event: Event): PromiseLike { - return this._sendRequest(eventToSentryRequest(event, this._api), event); + return this._sendRequest(eventToSentryRequest(event, this._dsn), event); } /** * @inheritDoc */ public sendSession(session: Session): PromiseLike { - return this._sendRequest(sessionToSentryRequest(session, this._api), session); + return this._sendRequest(sessionToSentryRequest(session, this._dsn), session); } /** diff --git a/packages/browser/src/transports/xhr.ts b/packages/browser/src/transports/xhr.ts index afbecf24239f..82a1f1179132 100644 --- a/packages/browser/src/transports/xhr.ts +++ b/packages/browser/src/transports/xhr.ts @@ -10,14 +10,14 @@ export class XHRTransport extends BaseTransport { * @inheritDoc */ public sendEvent(event: Event): PromiseLike { - return this._sendRequest(eventToSentryRequest(event, this._api), event); + return this._sendRequest(eventToSentryRequest(event, this._dsn), event); } /** * @inheritDoc */ public sendSession(session: Session): PromiseLike { - return this._sendRequest(sessionToSentryRequest(session, this._api), session); + return this._sendRequest(sessionToSentryRequest(session, this._dsn), session); } /** diff --git a/packages/core/src/api.ts b/packages/core/src/api.ts deleted file mode 100644 index c79012ec103a..000000000000 --- a/packages/core/src/api.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { DsnLike, SdkMetadata } from '@sentry/types'; -import { Dsn, urlEncode } from '@sentry/utils'; - -const SENTRY_API_VERSION = '7'; - -/** - * Helper class to provide urls, headers and metadata that can be used to form - * different types of requests to Sentry endpoints. - * Supports both envelopes and regular event requests. - **/ -export class API { - /** The DSN as passed to Sentry.init() */ - public dsn: DsnLike; - - /** Metadata about the SDK (name, version, etc) for inclusion in envelope headers */ - public metadata: SdkMetadata; - - /** The internally used Dsn object. */ - private readonly _dsnObject: Dsn; - - /** Create a new instance of API */ - public constructor(dsn: DsnLike, metadata: SdkMetadata = {}) { - this.dsn = dsn; - this._dsnObject = new Dsn(dsn); - this.metadata = metadata; - } - - /** Returns the Dsn object. */ - public getDsn(): Dsn { - return this._dsnObject; - } - - /** Returns the prefix to construct Sentry ingestion API endpoints. */ - public getBaseApiEndpoint(): string { - const dsn = this._dsnObject; - const protocol = dsn.protocol ? `${dsn.protocol}:` : ''; - const port = dsn.port ? `:${dsn.port}` : ''; - return `${protocol}//${dsn.host}${port}${dsn.path ? `/${dsn.path}` : ''}/api/`; - } - - /** Returns the store endpoint URL. */ - public getStoreEndpoint(): string { - return this._getIngestEndpoint('store'); - } - - /** - * Returns the store endpoint URL with auth in the query string. - * - * Sending auth as part of the query string and not as custom HTTP headers avoids CORS preflight requests. - */ - public getStoreEndpointWithUrlEncodedAuth(): string { - return `${this.getStoreEndpoint()}?${this._encodedAuth()}`; - } - - /** - * Returns the envelope endpoint URL with auth in the query string. - * - * Sending auth as part of the query string and not as custom HTTP headers avoids CORS preflight requests. - */ - public getEnvelopeEndpointWithUrlEncodedAuth(): string { - return `${this._getEnvelopeEndpoint()}?${this._encodedAuth()}`; - } - - /** Returns only the path component for the store endpoint. */ - public getStoreEndpointPath(): string { - const dsn = this._dsnObject; - return `${dsn.path ? `/${dsn.path}` : ''}/api/${dsn.projectId}/store/`; - } - - /** - * Returns an object that can be used in request headers. - * This is needed for node and the old /store endpoint in sentry - */ - public getRequestHeaders(clientName: string, clientVersion: string): { [key: string]: string } { - // CHANGE THIS to use metadata but keep clientName and clientVersion compatible - const dsn = this._dsnObject; - const header = [`Sentry sentry_version=${SENTRY_API_VERSION}`]; - header.push(`sentry_client=${clientName}/${clientVersion}`); - header.push(`sentry_key=${dsn.publicKey}`); - if (dsn.pass) { - header.push(`sentry_secret=${dsn.pass}`); - } - return { - 'Content-Type': 'application/json', - 'X-Sentry-Auth': header.join(', '), - }; - } - - /** Returns the url to the report dialog endpoint. */ - public getReportDialogEndpoint( - dialogOptions: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - [key: string]: any; - user?: { name?: string; email?: string }; - } = {}, - ): string { - const dsn = this._dsnObject; - const endpoint = `${this.getBaseApiEndpoint()}embed/error-page/`; - - const encodedOptions = []; - encodedOptions.push(`dsn=${dsn.toString()}`); - for (const key in dialogOptions) { - if (key === 'dsn') { - continue; - } - - if (key === 'user') { - if (!dialogOptions.user) { - continue; - } - if (dialogOptions.user.name) { - encodedOptions.push(`name=${encodeURIComponent(dialogOptions.user.name)}`); - } - if (dialogOptions.user.email) { - encodedOptions.push(`email=${encodeURIComponent(dialogOptions.user.email)}`); - } - } else { - encodedOptions.push(`${encodeURIComponent(key)}=${encodeURIComponent(dialogOptions[key] as string)}`); - } - } - if (encodedOptions.length) { - return `${endpoint}?${encodedOptions.join('&')}`; - } - - return endpoint; - } - - /** Returns the envelope endpoint URL. */ - private _getEnvelopeEndpoint(): string { - return this._getIngestEndpoint('envelope'); - } - - /** Returns the ingest API endpoint for target. */ - private _getIngestEndpoint(target: 'store' | 'envelope'): string { - const base = this.getBaseApiEndpoint(); - const dsn = this._dsnObject; - return `${base}${dsn.projectId}/${target}/`; - } - - /** Returns a URL-encoded string with auth config suitable for a query string. */ - private _encodedAuth(): string { - const dsn = this._dsnObject; - const auth = { - // We send only the minimum set of required information. See - // https://github.com/getsentry/sentry-javascript/issues/2572. - sentry_key: dsn.publicKey, - sentry_version: SENTRY_API_VERSION, - }; - return urlEncode(auth); - } -} diff --git a/packages/core/src/baseclient.ts b/packages/core/src/baseclient.ts index 6f9573dc4bd1..bdeb0f980e17 100644 --- a/packages/core/src/baseclient.ts +++ b/packages/core/src/baseclient.ts @@ -12,7 +12,6 @@ import { } from '@sentry/types'; import { dateTimestampInSeconds, - Dsn, isPrimitive, isThenable, logger, @@ -23,6 +22,7 @@ import { uuid4, } from '@sentry/utils'; +import { Dsn } from './dsn'; import { Backend, BackendClass } from './basebackend'; import { IntegrationIndex, setupIntegrations } from './integration'; diff --git a/packages/core/src/dsn.ts b/packages/core/src/dsn.ts new file mode 100644 index 000000000000..77e788e37ae5 --- /dev/null +++ b/packages/core/src/dsn.ts @@ -0,0 +1,139 @@ +const DSN_REGEX = /^(?:(\w+):)\/\/(?:(\w+)(?::\w+)?@)([\w.-]+)(?::(\d+))?\/(.+)/; +const INVALID_DSN = `Invalid DSN`; + +export type ReportDialogOptions = { + eventId?: string; + dsn?: string; + user?: { + email?: string; + name?: string; + }; + lang?: string; + title?: string; + subtitle?: string; + subtitle2?: string; + labelName?: string; + labelEmail?: string; + labelComments?: string; + labelClose?: string; + labelSubmit?: string; + errorGeneric?: string; + errorFormEntry?: string; + successMessage?: string; +}; + +function encodeUrlParams(values: Record): string { + return Object.keys(values) + .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(values[key])}`) + .join('&'); +} + +export function getReportDialogEndpoint(dsn: Dsn, dialogOptions: ReportDialogOptions = {}): string { + const dsnUrl = dialogOptions.dsn || dsn.toString(); + const dialogParams: Record = {}; + + Object.keys(dialogOptions).forEach(key => { + if (key === 'dsn') { + return; + } + + if (key === 'user') { + if (dialogOptions.user?.name) { + dialogParams.name = dialogOptions.user?.name; + } + if (dialogOptions.user?.email) { + dialogParams.email = dialogOptions.user?.email; + } + return; + } + + const value = dialogOptions[key as keyof Omit]; + if (value) { + dialogParams[key] = value; + } + }); + + const encodedParams = encodeUrlParams(dialogParams); + const optionsQueryString = encodedParams ? `&${encodedParams}` : ''; + + return `${dsn.getApiEndpoint()}/embed/error-page/?dsn=${dsnUrl}${optionsQueryString}`; +} + +export class Dsn { + public protocol: 'http' | 'https'; + public publicKey: string; + public host: string; + public port?: string; + public path?: string; + public projectId: string; + + public constructor(url: string) { + const dsnMatch = DSN_REGEX.exec(url); + + if (!dsnMatch) { + // TODO: Use SentryError + throw new Error(INVALID_DSN); + } + + const [protocol, publicKey, host, port, projetIdWithOptionalPath] = dsnMatch.slice(1); + + let path; + let projectId; + + // Extract path from the projectId, eg. `/foo/bar/123` => `foo/bar` + `123` + const pathParts = projetIdWithOptionalPath.split('/'); + if (pathParts.length > 1) { + projectId = pathParts.pop() as string; + path = pathParts.join('/'); + } else { + projectId = projetIdWithOptionalPath; + } + + // Trim query string and fragment from projectId, eg. `123?foo=bar#baz` => `123` + const projectMatch = projectId.match(/^\d+/); + if (projectMatch) { + projectId = projectMatch[0]; + } + + if (protocol !== 'http' && protocol !== 'https') { + // TODO: Use SentryError + throw new Error(`${INVALID_DSN} protocol: ${protocol}`); + } + + if (!projectId.match(/^\d+$/)) { + // TODO: Use SentryError + throw new Error(`${INVALID_DSN} projectId: ${projectId}`); + } + + this.protocol = protocol; + this.publicKey = publicKey; + this.host = host; + this.port = port; + this.path = path; + this.projectId = projectId; + } + + public toString(): string { + const { host, projectId, protocol, publicKey } = this; + const port = this.port ? `:${this.port}` : ''; + const path = this.path ? `/${this.path}` : ''; + return `${protocol}://${publicKey}@${host}${port}${path}/${projectId}`; + } + + public getApiEndpoint(): string { + const { host, protocol } = this; + const port = this.port ? `:${this.port}` : ''; + const path = this.path ? `/${this.path}` : ''; + return `${protocol}://${host}${port}${path}/api`; + } + + public getEnvelopeEndpoint(): string { + // We send only the minimum set of required information. + // See https://github.com/getsentry/sentry-javascript/issues/2572 + const auth = { + sentry_key: this.publicKey, + sentry_version: '7', + }; + return `${this.getApiEndpoint()}/${this.projectId}/envelope/?${encodeUrlParams(auth)}`; + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5fe8450de3ed..08bc2b9f287a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -14,7 +14,7 @@ export { withScope, } from '@sentry/minimal'; export { addGlobalEventProcessor, getCurrentHub, getHubFromCarrier, Hub, makeMain, Scope } from '@sentry/hub'; -export { API } from './api'; +export { Dsn, getReportDialogEndpoint, ReportDialogOptions } from './dsn'; export { BaseClient } from './baseclient'; export { BackendClass, BaseBackend } from './basebackend'; export { eventToSentryRequest, sessionToSentryRequest } from './request'; diff --git a/packages/core/src/request.ts b/packages/core/src/request.ts index 929841447fe7..48db6fc45e8e 100644 --- a/packages/core/src/request.ts +++ b/packages/core/src/request.ts @@ -1,42 +1,32 @@ -import { Event, SdkInfo, SentryRequest, Session } from '@sentry/types'; +import { Event, SentryRequest, Session } from '@sentry/types'; -import { API } from './api'; - -/** Extract sdk info from from the API metadata */ -function getSdkMetadataForEnvelopeHeader(api: API): SdkInfo | undefined { - if (!api.metadata || !api.metadata.sdk) { - return; - } - const { name, version } = api.metadata.sdk; - return { name, version }; -} +import { Dsn } from './dsn'; /** * Apply SdkInfo (name, version, packages, integrations) to the corresponding event key. * Merge with existing data if any. **/ -function enhanceEventWithSdkInfo(event: Event, sdkInfo?: SdkInfo): Event { - if (!sdkInfo) { - return event; - } +// TODO: Restore this functionality +// function enhanceEventWithSdkInfo(event: Event, sdkInfo?: SdkInfo): Event { +// if (!sdkInfo) { +// return event; +// } - event.sdk = event.sdk || { - name: sdkInfo.name, - version: sdkInfo.version, - }; - event.sdk.name = event.sdk.name || sdkInfo.name; - event.sdk.version = event.sdk.version || sdkInfo.version; - event.sdk.integrations = [...(event.sdk.integrations || []), ...(sdkInfo.integrations || [])]; - event.sdk.packages = [...(event.sdk.packages || []), ...(sdkInfo.packages || [])]; - return event; -} +// event.sdk = event.sdk || { +// name: sdkInfo.name, +// version: sdkInfo.version, +// }; +// event.sdk.name = event.sdk.name || sdkInfo.name; +// event.sdk.version = event.sdk.version || sdkInfo.version; +// event.sdk.integrations = [...(event.sdk.integrations || []), ...(sdkInfo.integrations || [])]; +// event.sdk.packages = [...(event.sdk.packages || []), ...(sdkInfo.packages || [])]; +// return event; +// } /** Creates a SentryRequest from an event. */ -export function sessionToSentryRequest(session: Session, api: API): SentryRequest { - const sdkInfo = getSdkMetadataForEnvelopeHeader(api); +export function sessionToSentryRequest(session: Session, dsn: Dsn): SentryRequest { const envelopeHeaders = JSON.stringify({ sent_at: new Date().toISOString(), - ...(sdkInfo && { sdk: sdkInfo }), }); const itemHeaders = JSON.stringify({ type: 'session', @@ -45,15 +35,13 @@ export function sessionToSentryRequest(session: Session, api: API): SentryReques return { body: `${envelopeHeaders}\n${itemHeaders}\n${JSON.stringify(session)}`, type: 'session', - url: api.getEnvelopeEndpointWithUrlEncodedAuth(), + url: dsn.getEnvelopeEndpoint(), }; } /** Creates a SentryRequest from an event. */ -export function eventToSentryRequest(event: Event, api: API): SentryRequest { - const sdkInfo = getSdkMetadataForEnvelopeHeader(api); +export function eventToSentryRequest(event: Event, dsn: Dsn): SentryRequest { const eventType = event.type || 'event'; - const useEnvelope = eventType === 'transaction'; const { transactionSampling, ...metadata } = event.debug_meta || {}; const { method: samplingMethod, rate: sampleRate } = transactionSampling || {}; @@ -64,51 +52,43 @@ export function eventToSentryRequest(event: Event, api: API): SentryRequest { } const req: SentryRequest = { - body: JSON.stringify(sdkInfo ? enhanceEventWithSdkInfo(event, api.metadata.sdk) : event), + body: JSON.stringify(event), type: eventType, - url: useEnvelope ? api.getEnvelopeEndpointWithUrlEncodedAuth() : api.getStoreEndpointWithUrlEncodedAuth(), + url: dsn.getEnvelopeEndpoint(), }; // https://develop.sentry.dev/sdk/envelopes/ + const envelopeHeaders = JSON.stringify({ + event_id: event.event_id, + sent_at: new Date().toISOString(), + }); + const itemHeaders = JSON.stringify({ + type: event.type, - // Since we don't need to manipulate envelopes nor store them, there is no - // exported concept of an Envelope with operations including serialization and - // deserialization. Instead, we only implement a minimal subset of the spec to - // serialize events inline here. - if (useEnvelope) { - const envelopeHeaders = JSON.stringify({ - event_id: event.event_id, - sent_at: new Date().toISOString(), - ...(sdkInfo && { sdk: sdkInfo }), - }); - const itemHeaders = JSON.stringify({ - type: event.type, - - // TODO: Right now, sampleRate may or may not be defined (it won't be in the cases of inheritance and - // explicitly-set sampling decisions). Are we good with that? - sample_rates: [{ id: samplingMethod, rate: sampleRate }], + // TODO: Right now, sampleRate may or may not be defined (it won't be in the cases of inheritance and + // explicitly-set sampling decisions). Are we good with that? + sample_rates: [{ id: samplingMethod, rate: sampleRate }], - // The content-type is assumed to be 'application/json' and not part of - // the current spec for transaction items, so we don't bloat the request - // body with it. - // - // content_type: 'application/json', - // - // The length is optional. It must be the number of bytes in req.Body - // encoded as UTF-8. Since the server can figure this out and would - // otherwise refuse events that report the length incorrectly, we decided - // not to send the length to avoid problems related to reporting the wrong - // size and to reduce request body size. - // - // length: new TextEncoder().encode(req.body).length, - }); - // The trailing newline is optional. We intentionally don't send it to avoid - // sending unnecessary bytes. + // The content-type is assumed to be 'application/json' and not part of + // the current spec for transaction items, so we don't bloat the request + // body with it. // - // const envelope = `${envelopeHeaders}\n${itemHeaders}\n${req.body}\n`; - const envelope = `${envelopeHeaders}\n${itemHeaders}\n${req.body}`; - req.body = envelope; - } + // content_type: 'application/json', + // + // The length is optional. It must be the number of bytes in req.Body + // encoded as UTF-8. Since the server can figure this out and would + // otherwise refuse events that report the length incorrectly, we decided + // not to send the length to avoid problems related to reporting the wrong + // size and to reduce request body size. + // + // length: new TextEncoder().encode(req.body).length, + }); + // The trailing newline is optional. We intentionally don't send it to avoid + // sending unnecessary bytes. + // + // const envelope = `${envelopeHeaders}\n${itemHeaders}\n${req.body}\n`; + const envelope = `${envelopeHeaders}\n${itemHeaders}\n${req.body}`; + req.body = envelope; return req; } diff --git a/packages/core/test/lib/api.test.ts b/packages/core/test/lib/api.test.ts deleted file mode 100644 index f25ae992abff..000000000000 --- a/packages/core/test/lib/api.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { Dsn } from '@sentry/utils'; - -import { API } from '../../src/api'; - -const ingestDsn = 'https://abc@xxxx.ingest.sentry.io:1234/subpath/123'; -const dsnPublic = 'https://abc@sentry.io:1234/subpath/123'; -const legacyDsn = 'https://abc:123@sentry.io:1234/subpath/123'; - -describe('API', () => { - test('getStoreEndpoint', () => { - expect(new API(dsnPublic).getStoreEndpointWithUrlEncodedAuth()).toEqual( - 'https://sentry.io:1234/subpath/api/123/store/?sentry_key=abc&sentry_version=7', - ); - expect(new API(dsnPublic).getStoreEndpoint()).toEqual('https://sentry.io:1234/subpath/api/123/store/'); - expect(new API(ingestDsn).getStoreEndpoint()).toEqual('https://xxxx.ingest.sentry.io:1234/subpath/api/123/store/'); - }); - - test('getRequestHeaders', () => { - expect(new API(dsnPublic).getRequestHeaders('a', '1.0')).toMatchObject({ - 'Content-Type': 'application/json', - 'X-Sentry-Auth': expect.stringMatching(/^Sentry sentry_version=\d, sentry_client=a\/1\.0, sentry_key=abc$/), - }); - - expect(new API(legacyDsn).getRequestHeaders('a', '1.0')).toMatchObject({ - 'Content-Type': 'application/json', - 'X-Sentry-Auth': expect.stringMatching( - /^Sentry sentry_version=\d, sentry_client=a\/1\.0, sentry_key=abc, sentry_secret=123$/, - ), - }); - }); - - test('getReportDialogEndpoint', () => { - expect(new API(ingestDsn).getReportDialogEndpoint({})).toEqual( - 'https://xxxx.ingest.sentry.io:1234/subpath/api/embed/error-page/?dsn=https://abc@xxxx.ingest.sentry.io:1234/subpath/123', - ); - - expect(new API(dsnPublic).getReportDialogEndpoint({})).toEqual( - 'https://sentry.io:1234/subpath/api/embed/error-page/?dsn=https://abc@sentry.io:1234/subpath/123', - ); - expect( - new API(dsnPublic).getReportDialogEndpoint({ - eventId: 'abc', - testy: '2', - }), - ).toEqual( - 'https://sentry.io:1234/subpath/api/embed/error-page/?dsn=https://abc@sentry.io:1234/subpath/123&eventId=abc&testy=2', - ); - - expect( - new API(dsnPublic).getReportDialogEndpoint({ - eventId: 'abc', - user: { - email: 'email', - name: 'yo', - }, - }), - ).toEqual( - 'https://sentry.io:1234/subpath/api/embed/error-page/?dsn=https://abc@sentry.io:1234/subpath/123&eventId=abc&name=yo&email=email', - ); - - expect( - new API(dsnPublic).getReportDialogEndpoint({ - eventId: 'abc', - user: undefined, - }), - ).toEqual( - 'https://sentry.io:1234/subpath/api/embed/error-page/?dsn=https://abc@sentry.io:1234/subpath/123&eventId=abc', - ); - }); - test('getDsn', () => { - expect(new API(dsnPublic).getDsn()).toEqual(new Dsn(dsnPublic)); - }); -}); diff --git a/packages/core/test/lib/dsn.test.ts b/packages/core/test/lib/dsn.test.ts new file mode 100644 index 000000000000..af48f525ec76 --- /dev/null +++ b/packages/core/test/lib/dsn.test.ts @@ -0,0 +1,143 @@ +import { Dsn, getReportDialogEndpoint } from '../../src/dsn'; + +describe('Dsn', () => { + describe('parsing', () => { + test('should parse public dsn', () => { + const dsn = new Dsn('https://abc@sentry.io/123'); + expect(dsn.protocol).toEqual('https'); + expect(dsn.publicKey).toEqual('abc'); + expect(dsn.host).toEqual('sentry.io'); + expect(dsn.port).toEqual(undefined); + expect(dsn.path).toEqual(undefined); + expect(dsn.projectId).toEqual('123'); + }); + + test('should parse ingest dsn', () => { + const dsn = new Dsn('https://abc@xxx.ingest.sentry.io/123'); + expect(dsn.protocol).toEqual('https'); + expect(dsn.publicKey).toEqual('abc'); + expect(dsn.host).toEqual('xxx.ingest.sentry.io'); + expect(dsn.port).toEqual(undefined); + expect(dsn.path).toEqual(undefined); + expect(dsn.projectId).toEqual('123'); + }); + + test('should parse legacy dsn', () => { + const dsn = new Dsn('https://abc:xyz@sentry.io/123'); + expect(dsn.protocol).toEqual('https'); + expect(dsn.publicKey).toEqual('abc'); + expect(dsn.host).toEqual('sentry.io'); + expect(dsn.port).toEqual(undefined); + expect(dsn.path).toEqual(undefined); + expect(dsn.projectId).toEqual('123'); + }); + + test('should parse a dsn with port and path', () => { + const dsn = new Dsn('https://abc@sentry.io:1234/custom/subpath/123'); + expect(dsn.protocol).toEqual('https'); + expect(dsn.publicKey).toEqual('abc'); + expect(dsn.host).toEqual('sentry.io'); + expect(dsn.port).toEqual('1234'); + expect(dsn.path).toEqual('custom/subpath'); + expect(dsn.projectId).toEqual('123'); + }); + + test('should parse a dsn with query string and fragment and ignore them', () => { + const dsn = new Dsn('https://abc@sentry.io/123?sample.rate=0.1&other=value#wat'); + expect(dsn.protocol).toEqual('https'); + expect(dsn.publicKey).toEqual('abc'); + expect(dsn.host).toEqual('sentry.io'); + expect(dsn.port).toEqual(undefined); + expect(dsn.path).toEqual(undefined); + expect(dsn.projectId).toEqual('123'); + }); + + test('should throw with invalid dsn', () => { + // missing protocol + expect(() => new Dsn('://abc@sentry.io/123')).toThrow('Invalid DSN'); + // missing host + expect(() => new Dsn('https://abc@123')).toThrow('Invalid DSN'); + // missing publicKey + expect(() => new Dsn('https://@sentry.io/123')).toThrow('Invalid DSN'); + // missing projectId + expect(() => new Dsn('https://abc@sentry.io/')).toThrow('Invalid DSN'); + // incorrect port + expect(() => new Dsn('http://abc@sentry.io:xxx/123')).toThrow('Invalid DSN'); + // incorrect protocol + expect(() => new Dsn('httpx://abc@sentry.io/123')).toThrow('Invalid DSN protocol: httpx'); + // incorrect projectId + expect(() => new Dsn('http://abc@sentry.io/abc')).toThrow('Invalid DSN projectId: abc'); + }); + }); + + describe('toString()', () => { + test('should render public dsn', () => { + const dsn = new Dsn('https://abc@sentry.io/123'); + expect(dsn.toString()).toEqual('https://abc@sentry.io/123'); + }); + + test('should render ingest dsn', () => { + const dsn = new Dsn('https://abc@xxx.ingest.sentry.io/123'); + expect(dsn.toString()).toEqual('https://abc@xxx.ingest.sentry.io/123'); + }); + + test('should render port and path, and ignore password', () => { + const dsn = new Dsn('https://abc:xyz@sentry.io:1234/custom/subpath/321'); + expect(dsn.toString()).toEqual('https://abc@sentry.io:1234/custom/subpath/321'); + }); + + test('should be able to recreate the same dsn from returned string', () => { + const dsn = new Dsn('https://abc:xyz@sentry.io:1234/custom/subpath/321'); + const recreatedDsn = new Dsn(dsn.toString()); + expect(dsn.toString()).toEqual(recreatedDsn.toString()); + }); + }); + + describe('getApiEndpoint()', () => { + test('should render protocol, host, port and path, followed by `/api` suffix', () => { + const dsn = new Dsn('https://abc@xxx.ingest.sentry.io:1234/custom/subpath/123'); + expect(dsn.getApiEndpoint()).toEqual('https://xxx.ingest.sentry.io:1234/custom/subpath/api'); + }); + }); + + describe('toEnvelopeEndpoint()', () => { + test('should render getApiEndpoint() followed by projectId, literal `/envelope/` and encoded url params', () => { + const dsn = new Dsn('https://abc@xxx.ingest.sentry.io:1234/custom/subpath/123'); + expect(dsn.getEnvelopeEndpoint()).toEqual( + 'https://xxx.ingest.sentry.io:1234/custom/subpath/api/123/envelope/?sentry_key=abc&sentry_version=7', + ); + }); + }); +}); + +describe('getReportDialogEndpoint', () => { + test('should render `/embed/error-page` url with provided dsn', () => { + expect(getReportDialogEndpoint(new Dsn('https://abc@xxx.ingest.sentry.io:1234/custom/subpath/123'), {})).toEqual( + 'https://xxx.ingest.sentry.io:1234/custom/subpath/api/embed/error-page/?dsn=https://abc@xxx.ingest.sentry.io:1234/custom/subpath/123', + ); + }); + + test('should render `/embed/error-page` url with overridden dsn', () => { + expect( + getReportDialogEndpoint(new Dsn('https://abc@xxx.ingest.sentry.io:1234/custom/subpath/123'), { + dsn: 'https://cba@xxx.ingest.sentry.io/321', + }), + ).toEqual( + 'https://xxx.ingest.sentry.io:1234/custom/subpath/api/embed/error-page/?dsn=https://cba@xxx.ingest.sentry.io/321', + ); + }); + + test('should render passed options as query string', () => { + expect( + getReportDialogEndpoint(new Dsn('https://abc@xxx.ingest.sentry.io:1234/custom/subpath/123'), { + user: { + email: 'pickle.rick@example.com', + name: 'Rick', + }, + title: "I'm a pickle Morty!", + }), + ).toEqual( + "https://xxx.ingest.sentry.io:1234/custom/subpath/api/embed/error-page/?dsn=https://abc@xxx.ingest.sentry.io:1234/custom/subpath/123&name=Rick&email=pickle.rick%40example.com&title=I'm%20a%20pickle%20Morty!", + ); + }); +}); diff --git a/packages/utils/src/dsn.ts b/packages/utils/src/dsn.ts deleted file mode 100644 index 011472e35234..000000000000 --- a/packages/utils/src/dsn.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { DsnComponents, DsnLike, DsnProtocol } from '@sentry/types'; - -import { SentryError } from './error'; - -/** Regular expression used to parse a Dsn. */ -const DSN_REGEX = /^(?:(\w+):)\/\/(?:(\w+)(?::(\w+))?@)([\w.-]+)(?::(\d+))?\/(.+)/; - -/** Error message */ -const ERROR_MESSAGE = 'Invalid Dsn'; - -/** The Sentry Dsn, identifying a Sentry instance and project. */ -export class Dsn implements DsnComponents { - /** Protocol used to connect to Sentry. */ - public protocol!: DsnProtocol; - /** Public authorization key (deprecated, renamed to publicKey). */ - public user!: string; - /** Public authorization key. */ - public publicKey!: string; - /** Private authorization key (deprecated, optional). */ - public pass!: string; - /** Hostname of the Sentry instance. */ - public host!: string; - /** Port of the Sentry instance. */ - public port!: string; - /** Path */ - public path!: string; - /** Project ID */ - public projectId!: string; - - /** Creates a new Dsn component */ - public constructor(from: DsnLike) { - if (typeof from === 'string') { - this._fromString(from); - } else { - this._fromComponents(from); - } - - this._validate(); - } - - /** - * Renders the string representation of this Dsn. - * - * By default, this will render the public representation without the password - * component. To get the deprecated private representation, set `withPassword` - * to true. - * - * @param withPassword When set to true, the password will be included. - */ - public toString(withPassword: boolean = false): string { - const { host, path, pass, port, projectId, protocol, publicKey } = this; - return ( - `${protocol}://${publicKey}${withPassword && pass ? `:${pass}` : ''}` + - `@${host}${port ? `:${port}` : ''}/${path ? `${path}/` : path}${projectId}` - ); - } - - /** Parses a string into this Dsn. */ - private _fromString(str: string): void { - const match = DSN_REGEX.exec(str); - - if (!match) { - throw new SentryError(ERROR_MESSAGE); - } - - const [protocol, publicKey, pass = '', host, port = '', lastPath] = match.slice(1); - let path = ''; - let projectId = lastPath; - - const split = projectId.split('/'); - if (split.length > 1) { - path = split.slice(0, -1).join('/'); - projectId = split.pop() as string; - } - - if (projectId) { - const projectMatch = projectId.match(/^\d+/); - if (projectMatch) { - projectId = projectMatch[0]; - } - } - - this._fromComponents({ host, pass, path, projectId, port, protocol: protocol as DsnProtocol, publicKey }); - } - - /** Maps Dsn components into this instance. */ - private _fromComponents(components: DsnComponents): void { - // TODO this is for backwards compatibility, and can be removed in a future version - if ('user' in components && !('publicKey' in components)) { - components.publicKey = components.user; - } - this.user = components.publicKey || ''; - - this.protocol = components.protocol; - this.publicKey = components.publicKey || ''; - this.pass = components.pass || ''; - this.host = components.host; - this.port = components.port || ''; - this.path = components.path || ''; - this.projectId = components.projectId; - } - - /** Validates this Dsn and throws on error. */ - private _validate(): void { - ['protocol', 'publicKey', 'host', 'projectId'].forEach(component => { - if (!this[component as keyof DsnComponents]) { - throw new SentryError(`${ERROR_MESSAGE}: ${component} missing`); - } - }); - - if (!this.projectId.match(/^\d+$/)) { - throw new SentryError(`${ERROR_MESSAGE}: Invalid projectId ${this.projectId}`); - } - - if (this.protocol !== 'http' && this.protocol !== 'https') { - throw new SentryError(`${ERROR_MESSAGE}: Invalid protocol ${this.protocol}`); - } - - if (this.port && isNaN(parseInt(this.port, 10))) { - throw new SentryError(`${ERROR_MESSAGE}: Invalid port ${this.port}`); - } - } -} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index da1983b5b7b6..e2686ff23349 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,6 +1,5 @@ export * from './async'; export * from './browser'; -export * from './dsn'; export * from './error'; export * from './instrument'; export * from './is'; diff --git a/packages/utils/src/object.ts b/packages/utils/src/object.ts index 40c1da433660..137985acafa9 100644 --- a/packages/utils/src/object.ts +++ b/packages/utils/src/object.ts @@ -44,18 +44,6 @@ export function fill(source: { [key: string]: any }, name: string, replacementFa source[name] = wrapped; } -/** - * Encodes given object into url-friendly format - * - * @param object An object that contains serializable values - * @returns string Encoded - */ -export function urlEncode(object: { [key: string]: any }): string { - return Object.keys(object) - .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(object[key])}`) - .join('&'); -} - /** * Transforms any object into an object literal with all its attributes * attached to it. diff --git a/packages/utils/test/dsn.test.ts b/packages/utils/test/dsn.test.ts deleted file mode 100644 index 534346cf0067..000000000000 --- a/packages/utils/test/dsn.test.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { Dsn } from '../src/dsn'; -import { SentryError } from '../src/error'; - -describe('Dsn', () => { - describe('fromComponents', () => { - test('applies all components', () => { - const dsn = new Dsn({ - host: 'sentry.io', - pass: 'xyz', - port: '1234', - projectId: '123', - protocol: 'https', - publicKey: 'abc', - }); - expect(dsn.protocol).toBe('https'); - expect(dsn.publicKey).toBe('abc'); - expect(dsn.pass).toBe('xyz'); - expect(dsn.host).toBe('sentry.io'); - expect(dsn.port).toBe('1234'); - expect(dsn.path).toBe(''); - expect(dsn.projectId).toBe('123'); - }); - - test('applies partial components', () => { - const dsn = new Dsn({ - host: 'sentry.io', - projectId: '123', - protocol: 'https', - publicKey: 'abc', - }); - expect(dsn.protocol).toBe('https'); - expect(dsn.publicKey).toBe('abc'); - expect(dsn.pass).toBe(''); - expect(dsn.host).toBe('sentry.io'); - expect(dsn.port).toBe(''); - expect(dsn.path).toBe(''); - expect(dsn.projectId).toBe('123'); - }); - - test('throws for missing components', () => { - expect( - () => - new Dsn({ - host: '', - projectId: '123', - protocol: 'https', - publicKey: 'abc', - }), - ).toThrow(SentryError); - expect( - () => - new Dsn({ - host: 'sentry.io', - projectId: '', - protocol: 'https', - publicKey: 'abc', - }), - ).toThrow(SentryError); - expect( - () => - new Dsn({ - host: 'sentry.io', - projectId: '123', - protocol: '' as 'http', // Trick the type checker here - publicKey: 'abc', - }), - ).toThrow(SentryError); - expect( - () => - new Dsn({ - host: 'sentry.io', - projectId: '123', - protocol: 'https', - publicKey: '', - }), - ).toThrow(SentryError); - }); - - test('throws for invalid components', () => { - expect( - () => - new Dsn({ - host: 'sentry.io', - projectId: '123', - protocol: 'httpx' as 'http', // Trick the type checker here - publicKey: 'abc', - }), - ).toThrow(SentryError); - expect( - () => - new Dsn({ - host: 'sentry.io', - port: 'xxx', - projectId: '123', - protocol: 'https', - publicKey: 'abc', - }), - ).toThrow(SentryError); - }); - }); - - describe('fromString', () => { - test('parses a valid full Dsn', () => { - const dsn = new Dsn('https://abc:xyz@sentry.io:1234/123'); - expect(dsn.protocol).toBe('https'); - expect(dsn.publicKey).toBe('abc'); - expect(dsn.pass).toBe('xyz'); - expect(dsn.host).toBe('sentry.io'); - expect(dsn.port).toBe('1234'); - expect(dsn.path).toBe(''); - expect(dsn.projectId).toBe('123'); - }); - - test('parses a valid partial Dsn', () => { - const dsn = new Dsn('https://abc@sentry.io/123/321'); - expect(dsn.protocol).toBe('https'); - expect(dsn.publicKey).toBe('abc'); - expect(dsn.pass).toBe(''); - expect(dsn.host).toBe('sentry.io'); - expect(dsn.port).toBe(''); - expect(dsn.path).toBe('123'); - expect(dsn.projectId).toBe('321'); - }); - - test('with a long path', () => { - const dsn = new Dsn('https://abc@sentry.io/sentry/custom/installation/321'); - expect(dsn.protocol).toBe('https'); - expect(dsn.publicKey).toBe('abc'); - expect(dsn.pass).toBe(''); - expect(dsn.host).toBe('sentry.io'); - expect(dsn.port).toBe(''); - expect(dsn.path).toBe('sentry/custom/installation'); - expect(dsn.projectId).toBe('321'); - }); - - test('with a query string', () => { - const dsn = new Dsn('https://abc@sentry.io/321?sample.rate=0.1&other=value'); - expect(dsn.protocol).toBe('https'); - expect(dsn.publicKey).toBe('abc'); - expect(dsn.pass).toBe(''); - expect(dsn.host).toBe('sentry.io'); - expect(dsn.port).toBe(''); - expect(dsn.path).toBe(''); - expect(dsn.projectId).toBe('321'); - }); - - test('throws when provided invalid Dsn', () => { - expect(() => new Dsn('some@random.dsn')).toThrow(SentryError); - }); - - test('throws without mandatory fields', () => { - expect(() => new Dsn('://abc@sentry.io/123')).toThrow(SentryError); - expect(() => new Dsn('https://@sentry.io/123')).toThrow(SentryError); - expect(() => new Dsn('https://abc@123')).toThrow(SentryError); - expect(() => new Dsn('https://abc@sentry.io/')).toThrow(SentryError); - }); - - test('throws for invalid fields', () => { - expect(() => new Dsn('httpx://abc@sentry.io/123')).toThrow(SentryError); - expect(() => new Dsn('httpx://abc@sentry.io:xxx/123')).toThrow(SentryError); - expect(() => new Dsn('http://abc@sentry.io/abc')).toThrow(SentryError); - }); - }); - - describe('toString', () => { - test('excludes the password by default', () => { - const dsn = new Dsn('https://abc:xyz@sentry.io:1234/123'); - expect(dsn.toString()).toBe('https://abc@sentry.io:1234/123'); - }); - - test('optionally includes the password', () => { - const dsn = new Dsn('https://abc:xyz@sentry.io:1234/123'); - expect(dsn.toString(true)).toBe('https://abc:xyz@sentry.io:1234/123'); - }); - - test('renders no password if missing', () => { - const dsn = new Dsn('https://abc@sentry.io:1234/123'); - expect(dsn.toString(true)).toBe('https://abc@sentry.io:1234/123'); - }); - - test('renders no port if missing', () => { - const dsn = new Dsn('https://abc@sentry.io/123'); - expect(dsn.toString()).toBe('https://abc@sentry.io/123'); - }); - - test('renders the full path correctly', () => { - const dsn = new Dsn('https://abc@sentry.io/sentry/custom/installation/321'); - expect(dsn.toString()).toBe('https://abc@sentry.io/sentry/custom/installation/321'); - }); - }); -});