Skip to content

Commit

Permalink
feat(core): Add ServerRuntimeClient (#8930)
Browse files Browse the repository at this point in the history
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`.
  • Loading branch information
timfish committed Sep 5, 2023
1 parent e2f0f4b commit f54e121
Show file tree
Hide file tree
Showing 5 changed files with 463 additions and 0 deletions.
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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';
Expand Down
172 changes: 172 additions & 0 deletions packages/core/src/server-runtime-client.ts
Original file line number Diff line number Diff line change
@@ -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<BaseTransportOptions> {
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<O> {
/**
* 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<Event> {
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<Event> {
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<Event | null> {
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<DynamicSamplingContext> | 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];
}
}
156 changes: 156 additions & 0 deletions packages/core/test/lib/serverruntimeclient.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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);
});
});
});

0 comments on commit f54e121

Please sign in to comment.