Skip to content

Commit

Permalink
feat(node): Add Spotlight option to Node SDK (#9629)
Browse files Browse the repository at this point in the history
This PR adds a new top level option called `spotlight` to Node init
options. Under the hood, if this option is true,
* all integrations will be forcefully initialized . This ensures that
without a DSN, we still capture and process events (but simply don't
send them to Sentry)
* a new `Spotlight` integration is added. This integration will make a
`http` post request to the sidecar URL. Either we take the default
sidecar URL or users provide their own URL:

```js
// enable/disable
Sentry.init({
  spotlight: process.env.NODE_ENV === "development"
});

// enbale by setting a custom URL
Sentry.init({
  spotlight: process.env.NODE_ENV === "development" ? 'http://localhost:7777' : false 
});
```

This option should also work in Node Experimental, given that Node
experimental just calls the node init function.

---------

Co-authored-by: Lukas Stracke <lukas.stracke@sentry.io>
  • Loading branch information
HazAT and Lms24 committed Nov 22, 2023
1 parent f7257a1 commit 92c9fbb
Show file tree
Hide file tree
Showing 5 changed files with 256 additions and 0 deletions.
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 '@sentry/core';
export { LocalVariables } from './localvariables';
export { Undici } from './undici';
export { Spotlight } from './spotlight';
110 changes: 110 additions & 0 deletions packages/node/src/integrations/spotlight.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
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
*/
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',
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');
},
);

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) {
// 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 option to `true` while developing, not in production!
*/
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;
});
});

0 comments on commit 92c9fbb

Please sign in to comment.