Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(node): Add Spotlight option to Node SDK #9629

Merged
merged 7 commits into from
Nov 22, 2023
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/node/src/integrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ export { Context } from './context';
export { RequestData } from './requestdata';
export { LocalVariables } from './localvariables';
export { Undici } from './undici';
export { Spotlight } from './spotlight';
113 changes: 113 additions & 0 deletions packages/node/src/integrations/spotlight.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import type { Client, Integration } from '@sentry/types';
import { logger, serializeEnvelope } from '@sentry/utils';
import * as http from 'http';
import { URL } from 'url';

type SpotlightConnectionOptions = {
/**
* Set this if the Spotlight Sidecar is not running on localhost:8969
* By default, the Url is set to http://localhost:8969
*/
sidecarUrl?: string;
};

/**
* Use this integration to send errors and transactions to Spotlight.
*
* Learn more about spotlight at https://spotlightjs.com
*
* Important: This integration only works with Node 18 or newer
*
* @param options
* @returns
Lms24 marked this conversation as resolved.
Show resolved Hide resolved
*/
export class Spotlight implements Integration {
public static id = 'Spotlight';
public name = Spotlight.id;

private readonly _options: Required<SpotlightConnectionOptions>;

public constructor(options?: SpotlightConnectionOptions) {
this._options = {
sidecarUrl: options?.sidecarUrl || 'http://localhost:8969',
};
}

/**
* JSDoc
*/
public setupOnce(): void {
// empty but otherwise TS complains
}

/**
* Sets up forwarding envelopes to the Spotlight Sidecar
*/
public setup(client: Client): void {
if (process.env.NODE_ENV !== 'development') {
logger.warn("[Spotlight] It seems you're not in dev mode. Do you really want to have Spoltight enabled?");
}
connectToSpotlight(client, this._options);
}
}

function connectToSpotlight(client: Client, options: Required<SpotlightConnectionOptions>): void {
const spotlightUrl = parseSidecarUrl(options.sidecarUrl);
if (!spotlightUrl) {
return;
}

let failedRequests = 0;

if (typeof client.on !== 'function') {
logger.warn('[Spotlight] Cannot connect to spotlight due to missing method on SDK client (`client.on`)');
return;
}

client.on('beforeEnvelope', envelope => {
if (failedRequests > 3) {
logger.warn('[Spotlight] Disabled Sentry -> Spotlight integration due to too many failed requests');
return;
}

const serializedEnvelope = serializeEnvelope(envelope);

const req = http.request(
{
method: 'POST',
path: '/stream',

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like spotlightUrl.path should be /stream already? Is hardcoding intentional?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I'm not mistaken (it's laaate here 😅) spotlightUrl is the optionally user configurable part of the URL - protocol, host and port. The path I think we're going to mandate to be /stream.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Id just make it the full URL tbqh. That makes it easier for us to swap out different proxies later if we wanted.

e.g. make it http://localhost:8969/stream as the default (or w/e it is)

hostname: spotlightUrl.hostname,
port: spotlightUrl.port,
headers: {
'Content-Type': 'application/x-sentry-envelope',
},
},
res => {
res.on('data', () => {
// Drain socket
});

res.on('end', () => {
// Drain socket
});
res.setEncoding('utf8');
},
);
Comment on lines +82 to +92
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tbh, I'm not sure if we need this but I found the same logic in the Http transport request creation function.


req.on('error', () => {
failedRequests++;
logger.warn('[Spotlight] Failed to send envelope to Spotlight Sidecar');
});
req.write(serializedEnvelope);
req.end();
});
}

function parseSidecarUrl(url: string): URL | undefined {
try {
return new URL(`${url}/stream`);
} catch {
logger.warn(`[Spotlight] Invalid sidecar URL: ${url}`);
return undefined;
}
}
12 changes: 12 additions & 0 deletions packages/node/src/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
OnUncaughtException,
OnUnhandledRejection,
RequestData,
Spotlight,
Undici,
} from './integrations';
import { getModuleFromFilename } from './module';
Expand Down Expand Up @@ -179,6 +180,17 @@ export function init(options: NodeOptions = {}): void {
}

updateScopeFromEnvVariables();

if (options.spotlight) {
const client = getCurrentHub().getClient();
if (client && client.addIntegration) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we check if this has run, and only then call this? Even if we check this inside of setupIntegrations(), may be a bit cleaner I'd say?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discussed this offline quickly: Checking this is not easy outside the BaseClient class. Given this call no-ops if integrations were already set up, let's keep it as is for now.

// force integrations to be setup even if no DSN was set
client.setupIntegrations(true);
client.addIntegration(
new Spotlight({ sidecarUrl: typeof options.spotlight === 'string' ? options.spotlight : undefined }),
);
}
}
}

/**
Expand Down
12 changes: 12 additions & 0 deletions packages/node/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,18 @@ export interface BaseNodeOptions {
* */
clientClass?: typeof NodeClient;

/**
* 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 optoin to `true` while developing, not in production!
Lms24 marked this conversation as resolved.
Show resolved Hide resolved
*/
spotlight?: boolean | string;

// TODO (v8): Remove this in v8
/**
* @deprecated Moved to constructor options of the `Http` and `Undici` integration.
Expand Down
121 changes: 121 additions & 0 deletions packages/node/test/integrations/spotlight.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import type { Envelope, EventEnvelope } from '@sentry/types';
import { createEnvelope, logger } from '@sentry/utils';
import * as http from 'http';

import { NodeClient } from '../../src';
import { Spotlight } from '../../src/integrations';
import { getDefaultNodeClientOptions } from '../helper/node-client-options';

describe('Spotlight', () => {
const loggerSpy = jest.spyOn(logger, 'warn');

afterEach(() => {
loggerSpy.mockClear();
jest.clearAllMocks();
});

const options = getDefaultNodeClientOptions();
const client = new NodeClient(options);

it('has a name and id', () => {
const integration = new Spotlight();
expect(integration.name).toEqual('Spotlight');
expect(Spotlight.id).toEqual('Spotlight');
});

it('registers a callback on the `beforeEnvelope` hook', () => {
const clientWithSpy = {
...client,
on: jest.fn(),
};
const integration = new Spotlight();
// @ts-expect-error - this is fine in tests
integration.setup(clientWithSpy);
expect(clientWithSpy.on).toHaveBeenCalledWith('beforeEnvelope', expect.any(Function));
});

it('sends an envelope POST request to the sidecar url', () => {
const httpSpy = jest.spyOn(http, 'request').mockImplementationOnce(() => {
return {
on: jest.fn(),
write: jest.fn(),
end: jest.fn(),
} as any;
});

let callback: (envelope: Envelope) => void = () => {};
const clientWithSpy = {
...client,
on: jest.fn().mockImplementationOnce((_, cb) => (callback = cb)),
};

const integration = new Spotlight();
// @ts-expect-error - this is fine in tests
integration.setup(clientWithSpy);

const envelope = createEnvelope<EventEnvelope>({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, [
[{ type: 'event' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }],
]);

callback(envelope);

expect(httpSpy).toHaveBeenCalledWith(
{
headers: {
'Content-Type': 'application/x-sentry-envelope',
},
hostname: 'localhost',
method: 'POST',
path: '/stream',
port: '8969',
},
expect.any(Function),
);
});

describe('no-ops if', () => {
it('an invalid URL is passed', () => {
const integration = new Spotlight({ sidecarUrl: 'invalid-url' });
integration.setup(client);
expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid sidecar URL: invalid-url'));
});

it("the client doesn't support life cycle hooks", () => {
const integration = new Spotlight({ sidecarUrl: 'http://mylocalhost:8969' });
const clientWithoutHooks = { ...client };
// @ts-expect-error - this is fine in tests
delete client.on;
// @ts-expect-error - this is fine in tests
integration.setup(clientWithoutHooks);
expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining(' missing method on SDK client (`client.on`)'));
});
});

it('warns if the NODE_ENV variable doesn\'t equal "development"', () => {
const oldEnvValue = process.env.NODE_ENV;
process.env.NODE_ENV = 'production';

const integration = new Spotlight({ sidecarUrl: 'http://localhost:8969' });
integration.setup(client);

expect(loggerSpy).toHaveBeenCalledWith(
expect.stringContaining("It seems you're not in dev mode. Do you really want to have Spoltight enabled?"),
);

process.env.NODE_ENV = oldEnvValue;
});

it('doesn\'t warn if the NODE_ENV variable equals "development"', () => {
const oldEnvValue = process.env.NODE_ENV;
process.env.NODE_ENV = 'development';

const integration = new Spotlight({ sidecarUrl: 'http://localhost:8969' });
integration.setup(client);

expect(loggerSpy).not.toHaveBeenCalledWith(
expect.stringContaining("It seems you're not in dev mode. Do you really want to have Spoltight enabled?"),
);

process.env.NODE_ENV = oldEnvValue;
});
});