From f54e12137ee86e43a4b2fdeb73f812912f67cfdd Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Tue, 5 Sep 2023 19:16:29 +0200 Subject: [PATCH] feat(core): Add `ServerRuntimeClient` (#8930) The `ServerRuntimeClient` is a near identical copy of the nextjs `EdgeClient`. To make it a direct replacement it has constructor options to override the event `platform`, `runtime`, and `server_name`. This PR makes yet another copy of the Node `eventbuilder.ts` but after future PRs to remove the `EdgeClient` and make `NodeClient` extend `ServerRuntimeClient`, this will be the only copy. I've put the `eventbuilder` code in utils since some of these functions are used elsewhere outside of the clients and I don't want to export these from core and them become part of our public API. This is especially important since the browser SDK already exports it's own slightly different `exceptionFromError`. --- packages/core/src/index.ts | 2 + packages/core/src/server-runtime-client.ts | 172 ++++++++++++++++++ .../core/test/lib/serverruntimeclient.test.ts | 156 ++++++++++++++++ packages/utils/src/eventbuilder.ts | 132 ++++++++++++++ packages/utils/src/index.ts | 1 + 5 files changed, 463 insertions(+) create mode 100644 packages/core/src/server-runtime-client.ts create mode 100644 packages/core/test/lib/serverruntimeclient.test.ts create mode 100644 packages/utils/src/eventbuilder.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a0cc7e627bf7..67c28a3e3c57 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,7 @@ export type { ClientClass } from './sdk'; export type { AsyncContextStrategy, Carrier, Layer, RunWithAsyncContextOptions } from './hub'; export type { OfflineStore, OfflineTransportOptions } from './transports/offline'; +export type { ServerRuntimeClientOptions } from './server-runtime-client'; export * from './tracing'; export { @@ -38,6 +39,7 @@ export { SessionFlusher } from './sessionflusher'; export { addGlobalEventProcessor, Scope } from './scope'; export { getEnvelopeEndpointWithUrlEncodedAuth, getReportDialogEndpoint } from './api'; export { BaseClient } from './baseclient'; +export { ServerRuntimeClient } from './server-runtime-client'; export { initAndBind } from './sdk'; export { createTransport } from './transports/base'; export { makeOfflineTransport } from './transports/offline'; diff --git a/packages/core/src/server-runtime-client.ts b/packages/core/src/server-runtime-client.ts new file mode 100644 index 000000000000..7f3d5a6cf315 --- /dev/null +++ b/packages/core/src/server-runtime-client.ts @@ -0,0 +1,172 @@ +import type { + BaseTransportOptions, + CheckIn, + ClientOptions, + DynamicSamplingContext, + Event, + EventHint, + MonitorConfig, + SerializedCheckIn, + Severity, + SeverityLevel, + TraceContext, +} from '@sentry/types'; +import { eventFromMessage, eventFromUnknownInput, logger, uuid4 } from '@sentry/utils'; + +import { BaseClient } from './baseclient'; +import { createCheckInEnvelope } from './checkin'; +import { getCurrentHub } from './hub'; +import type { Scope } from './scope'; +import { addTracingExtensions, getDynamicSamplingContextFromClient } from './tracing'; + +export interface ServerRuntimeClientOptions extends ClientOptions { + platform?: string; + runtime?: { name: string; version?: string }; + serverName?: string; +} + +/** + * The Sentry Server Runtime Client SDK. + */ +export class ServerRuntimeClient< + O extends ClientOptions & ServerRuntimeClientOptions = ServerRuntimeClientOptions, +> extends BaseClient { + /** + * Creates a new Edge SDK instance. + * @param options Configuration options for this SDK. + */ + public constructor(options: O) { + // Server clients always support tracing + addTracingExtensions(); + + super(options); + } + + /** + * @inheritDoc + */ + public eventFromException(exception: unknown, hint?: EventHint): PromiseLike { + return Promise.resolve(eventFromUnknownInput(getCurrentHub, this._options.stackParser, exception, hint)); + } + + /** + * @inheritDoc + */ + public eventFromMessage( + message: string, + // eslint-disable-next-line deprecation/deprecation + level: Severity | SeverityLevel = 'info', + hint?: EventHint, + ): PromiseLike { + return Promise.resolve( + eventFromMessage(this._options.stackParser, message, level, hint, this._options.attachStacktrace), + ); + } + + /** + * Create a cron monitor check in and send it to Sentry. + * + * @param checkIn An object that describes a check in. + * @param upsertMonitorConfig An optional object that describes a monitor config. Use this if you want + * to create a monitor automatically when sending a check in. + */ + public captureCheckIn(checkIn: CheckIn, monitorConfig?: MonitorConfig, scope?: Scope): string { + const id = checkIn.status !== 'in_progress' && checkIn.checkInId ? checkIn.checkInId : uuid4(); + if (!this._isEnabled()) { + __DEBUG_BUILD__ && logger.warn('SDK not enabled, will not capture checkin.'); + return id; + } + + const options = this.getOptions(); + const { release, environment, tunnel } = options; + + const serializedCheckIn: SerializedCheckIn = { + check_in_id: id, + monitor_slug: checkIn.monitorSlug, + status: checkIn.status, + release, + environment, + }; + + if (checkIn.status !== 'in_progress') { + serializedCheckIn.duration = checkIn.duration; + } + + if (monitorConfig) { + serializedCheckIn.monitor_config = { + schedule: monitorConfig.schedule, + checkin_margin: monitorConfig.checkinMargin, + max_runtime: monitorConfig.maxRuntime, + timezone: monitorConfig.timezone, + }; + } + + const [dynamicSamplingContext, traceContext] = this._getTraceInfoFromScope(scope); + if (traceContext) { + serializedCheckIn.contexts = { + trace: traceContext, + }; + } + + const envelope = createCheckInEnvelope( + serializedCheckIn, + dynamicSamplingContext, + this.getSdkMetadata(), + tunnel, + this.getDsn(), + ); + + __DEBUG_BUILD__ && logger.info('Sending checkin:', checkIn.monitorSlug, checkIn.status); + void this._sendEnvelope(envelope); + return id; + } + + /** + * @inheritDoc + */ + protected _prepareEvent(event: Event, hint: EventHint, scope?: Scope): PromiseLike { + if (this._options.platform) { + event.platform = event.platform || this._options.platform; + } + + if (this._options.runtime) { + event.contexts = { + ...event.contexts, + runtime: (event.contexts || {}).runtime || this._options.runtime, + }; + } + + if (this._options.serverName) { + event.server_name = event.server_name || this._options.serverName; + } + + return super._prepareEvent(event, hint, scope); + } + + /** Extract trace information from scope */ + private _getTraceInfoFromScope( + scope: Scope | undefined, + ): [dynamicSamplingContext: Partial | undefined, traceContext: TraceContext | undefined] { + if (!scope) { + return [undefined, undefined]; + } + + const span = scope.getSpan(); + if (span) { + const samplingContext = span.transaction ? span.transaction.getDynamicSamplingContext() : undefined; + return [samplingContext, span.getTraceContext()]; + } + + const { traceId, spanId, parentSpanId, dsc } = scope.getPropagationContext(); + const traceContext: TraceContext = { + trace_id: traceId, + span_id: spanId, + parent_span_id: parentSpanId, + }; + if (dsc) { + return [dsc, traceContext]; + } + + return [getDynamicSamplingContextFromClient(traceId, this, scope), traceContext]; + } +} diff --git a/packages/core/test/lib/serverruntimeclient.test.ts b/packages/core/test/lib/serverruntimeclient.test.ts new file mode 100644 index 000000000000..8f4c898fe580 --- /dev/null +++ b/packages/core/test/lib/serverruntimeclient.test.ts @@ -0,0 +1,156 @@ +import type { Event, EventHint } from '@sentry/types'; + +import { createTransport } from '../../src'; +import type { ServerRuntimeClientOptions } from '../../src/server-runtime-client'; +import { ServerRuntimeClient } from '../../src/server-runtime-client'; + +const PUBLIC_DSN = 'https://username@domain/123'; + +function getDefaultClientOptions(options: Partial = {}): ServerRuntimeClientOptions { + return { + integrations: [], + transport: () => createTransport({ recordDroppedEvent: () => undefined }, _ => Promise.resolve({})), + stackParser: () => [], + instrumenter: 'sentry', + ...options, + }; +} + +describe('ServerRuntimeClient', () => { + let client: ServerRuntimeClient; + + describe('_prepareEvent', () => { + test('adds platform to event', () => { + const options = getDefaultClientOptions({ dsn: PUBLIC_DSN }); + const client = new ServerRuntimeClient({ ...options, platform: 'edge' }); + + const event: Event = {}; + const hint: EventHint = {}; + (client as any)._prepareEvent(event, hint); + + expect(event.platform).toEqual('edge'); + }); + + test('adds server_name to event', () => { + const options = getDefaultClientOptions({ dsn: PUBLIC_DSN }); + const client = new ServerRuntimeClient({ ...options, serverName: 'server' }); + + const event: Event = {}; + const hint: EventHint = {}; + (client as any)._prepareEvent(event, hint); + + expect(event.server_name).toEqual('server'); + }); + + test('adds runtime context to event', () => { + const options = getDefaultClientOptions({ dsn: PUBLIC_DSN }); + const client = new ServerRuntimeClient({ ...options, runtime: { name: 'edge' } }); + + const event: Event = {}; + const hint: EventHint = {}; + (client as any)._prepareEvent(event, hint); + + expect(event.contexts?.runtime).toEqual({ + name: 'edge', + }); + }); + + test("doesn't clobber existing runtime data", () => { + const options = getDefaultClientOptions({ dsn: PUBLIC_DSN }); + const client = new ServerRuntimeClient({ ...options, runtime: { name: 'edge' } }); + + const event: Event = { contexts: { runtime: { name: 'foo', version: '1.2.3' } } }; + const hint: EventHint = {}; + (client as any)._prepareEvent(event, hint); + + expect(event.contexts?.runtime).toEqual({ name: 'foo', version: '1.2.3' }); + expect(event.contexts?.runtime).not.toEqual({ name: 'edge' }); + }); + }); + + describe('captureCheckIn', () => { + it('sends a checkIn envelope', () => { + const options = getDefaultClientOptions({ + dsn: PUBLIC_DSN, + serverName: 'bar', + release: '1.0.0', + environment: 'dev', + }); + client = new ServerRuntimeClient(options); + + // @ts-ignore 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('does not send a checkIn envelope if disabled', () => { + const options = getDefaultClientOptions({ dsn: PUBLIC_DSN, serverName: 'bar', enabled: false }); + client = new ServerRuntimeClient(options); + + // @ts-ignore accessing private method + const sendEnvelopeSpy = jest.spyOn(client, '_sendEnvelope'); + + client.captureCheckIn({ monitorSlug: 'foo', status: 'in_progress' }); + + expect(sendEnvelopeSpy).toHaveBeenCalledTimes(0); + }); + }); +}); diff --git a/packages/utils/src/eventbuilder.ts b/packages/utils/src/eventbuilder.ts new file mode 100644 index 000000000000..01e217921d87 --- /dev/null +++ b/packages/utils/src/eventbuilder.ts @@ -0,0 +1,132 @@ +import type { + Event, + EventHint, + Exception, + Hub, + Mechanism, + Severity, + SeverityLevel, + StackFrame, + StackParser, +} from '@sentry/types'; + +import { isError, isPlainObject } from './is'; +import { addExceptionMechanism, addExceptionTypeValue } from './misc'; +import { normalizeToSize } from './normalize'; +import { extractExceptionKeysForMessage } from './object'; + +/** + * Extracts stack frames from the error.stack string + */ +export function parseStackFrames(stackParser: StackParser, error: Error): StackFrame[] { + return stackParser(error.stack || '', 1); +} + +/** + * Extracts stack frames from the error and builds a Sentry Exception + */ +export function exceptionFromError(stackParser: StackParser, error: Error): Exception { + const exception: Exception = { + type: error.name || error.constructor.name, + value: error.message, + }; + + const frames = parseStackFrames(stackParser, error); + if (frames.length) { + exception.stacktrace = { frames }; + } + + return exception; +} + +/** + * Builds and Event from a Exception + * @hidden + */ +export function eventFromUnknownInput( + getCurrentHub: () => Hub, + stackParser: StackParser, + exception: unknown, + hint?: EventHint, +): Event { + let ex: unknown = exception; + const providedMechanism: Mechanism | undefined = + hint && hint.data && (hint.data as { mechanism: Mechanism }).mechanism; + const mechanism: Mechanism = providedMechanism || { + handled: true, + type: 'generic', + }; + + if (!isError(exception)) { + if (isPlainObject(exception)) { + // This will allow us to group events based on top-level keys + // which is much better than creating new group when any key/value change + const message = `Non-Error exception captured with keys: ${extractExceptionKeysForMessage(exception)}`; + + const hub = getCurrentHub(); + const client = hub.getClient(); + const normalizeDepth = client && client.getOptions().normalizeDepth; + hub.configureScope(scope => { + scope.setExtra('__serialized__', normalizeToSize(exception, normalizeDepth)); + }); + + ex = (hint && hint.syntheticException) || new Error(message); + (ex as Error).message = message; + } else { + // This handles when someone does: `throw "something awesome";` + // We use synthesized Error here so we can extract a (rough) stack trace. + ex = (hint && hint.syntheticException) || new Error(exception as string); + (ex as Error).message = exception as string; + } + mechanism.synthetic = true; + } + + const event = { + exception: { + values: [exceptionFromError(stackParser, ex as Error)], + }, + }; + + addExceptionTypeValue(event, undefined, undefined); + addExceptionMechanism(event, mechanism); + + return { + ...event, + event_id: hint && hint.event_id, + }; +} + +/** + * Builds and Event from a Message + * @hidden + */ +export function eventFromMessage( + stackParser: StackParser, + message: string, + // eslint-disable-next-line deprecation/deprecation + level: Severity | SeverityLevel = 'info', + hint?: EventHint, + attachStacktrace?: boolean, +): Event { + const event: Event = { + event_id: hint && hint.event_id, + level, + message, + }; + + if (attachStacktrace && hint && hint.syntheticException) { + const frames = parseStackFrames(stackParser, hint.syntheticException); + if (frames.length) { + event.exception = { + values: [ + { + value: message, + stacktrace: { frames }, + }, + ], + }; + } + } + + return event; +} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 0464dbec25da..8de4941f6b96 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -30,3 +30,4 @@ export * from './baggage'; export * from './url'; export * from './userIntegrations'; export * from './cache'; +export * from './eventbuilder';