Skip to content

Commit 92c9fbb

Browse files
HazATLms24
andauthored
feat(node): Add Spotlight option to Node SDK (#9629)
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 <[email protected]>
1 parent f7257a1 commit 92c9fbb

File tree

5 files changed

+256
-0
lines changed

5 files changed

+256
-0
lines changed

packages/node/src/integrations/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ export { Context } from './context';
88
export { RequestData } from '@sentry/core';
99
export { LocalVariables } from './localvariables';
1010
export { Undici } from './undici';
11+
export { Spotlight } from './spotlight';
+110
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import type { Client, Integration } from '@sentry/types';
2+
import { logger, serializeEnvelope } from '@sentry/utils';
3+
import * as http from 'http';
4+
import { URL } from 'url';
5+
6+
type SpotlightConnectionOptions = {
7+
/**
8+
* Set this if the Spotlight Sidecar is not running on localhost:8969
9+
* By default, the Url is set to http://localhost:8969
10+
*/
11+
sidecarUrl?: string;
12+
};
13+
14+
/**
15+
* Use this integration to send errors and transactions to Spotlight.
16+
*
17+
* Learn more about spotlight at https://spotlightjs.com
18+
*
19+
* Important: This integration only works with Node 18 or newer
20+
*/
21+
export class Spotlight implements Integration {
22+
public static id = 'Spotlight';
23+
public name = Spotlight.id;
24+
25+
private readonly _options: Required<SpotlightConnectionOptions>;
26+
27+
public constructor(options?: SpotlightConnectionOptions) {
28+
this._options = {
29+
sidecarUrl: options?.sidecarUrl || 'http://localhost:8969',
30+
};
31+
}
32+
33+
/**
34+
* JSDoc
35+
*/
36+
public setupOnce(): void {
37+
// empty but otherwise TS complains
38+
}
39+
40+
/**
41+
* Sets up forwarding envelopes to the Spotlight Sidecar
42+
*/
43+
public setup(client: Client): void {
44+
if (process.env.NODE_ENV !== 'development') {
45+
logger.warn("[Spotlight] It seems you're not in dev mode. Do you really want to have Spoltight enabled?");
46+
}
47+
connectToSpotlight(client, this._options);
48+
}
49+
}
50+
51+
function connectToSpotlight(client: Client, options: Required<SpotlightConnectionOptions>): void {
52+
const spotlightUrl = parseSidecarUrl(options.sidecarUrl);
53+
if (!spotlightUrl) {
54+
return;
55+
}
56+
57+
let failedRequests = 0;
58+
59+
if (typeof client.on !== 'function') {
60+
logger.warn('[Spotlight] Cannot connect to spotlight due to missing method on SDK client (`client.on`)');
61+
return;
62+
}
63+
64+
client.on('beforeEnvelope', envelope => {
65+
if (failedRequests > 3) {
66+
logger.warn('[Spotlight] Disabled Sentry -> Spotlight integration due to too many failed requests');
67+
return;
68+
}
69+
70+
const serializedEnvelope = serializeEnvelope(envelope);
71+
72+
const req = http.request(
73+
{
74+
method: 'POST',
75+
path: '/stream',
76+
hostname: spotlightUrl.hostname,
77+
port: spotlightUrl.port,
78+
headers: {
79+
'Content-Type': 'application/x-sentry-envelope',
80+
},
81+
},
82+
res => {
83+
res.on('data', () => {
84+
// Drain socket
85+
});
86+
87+
res.on('end', () => {
88+
// Drain socket
89+
});
90+
res.setEncoding('utf8');
91+
},
92+
);
93+
94+
req.on('error', () => {
95+
failedRequests++;
96+
logger.warn('[Spotlight] Failed to send envelope to Spotlight Sidecar');
97+
});
98+
req.write(serializedEnvelope);
99+
req.end();
100+
});
101+
}
102+
103+
function parseSidecarUrl(url: string): URL | undefined {
104+
try {
105+
return new URL(`${url}/stream`);
106+
} catch {
107+
logger.warn(`[Spotlight] Invalid sidecar URL: ${url}`);
108+
return undefined;
109+
}
110+
}

packages/node/src/sdk.ts

+12
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
OnUncaughtException,
2929
OnUnhandledRejection,
3030
RequestData,
31+
Spotlight,
3132
Undici,
3233
} from './integrations';
3334
import { getModuleFromFilename } from './module';
@@ -179,6 +180,17 @@ export function init(options: NodeOptions = {}): void {
179180
}
180181

181182
updateScopeFromEnvVariables();
183+
184+
if (options.spotlight) {
185+
const client = getCurrentHub().getClient();
186+
if (client && client.addIntegration) {
187+
// force integrations to be setup even if no DSN was set
188+
client.setupIntegrations(true);
189+
client.addIntegration(
190+
new Spotlight({ sidecarUrl: typeof options.spotlight === 'string' ? options.spotlight : undefined }),
191+
);
192+
}
193+
}
182194
}
183195

184196
/**

packages/node/src/types.ts

+12
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,18 @@ export interface BaseNodeOptions {
5959
* */
6060
clientClass?: typeof NodeClient;
6161

62+
/**
63+
* If you use Spotlight by Sentry during development, use
64+
* this option to forward captured Sentry events to Spotlight.
65+
*
66+
* Either set it to true, or provide a specific Spotlight Sidecar URL.
67+
*
68+
* More details: https://spotlightjs.com/
69+
*
70+
* IMPORTANT: Only set this option to `true` while developing, not in production!
71+
*/
72+
spotlight?: boolean | string;
73+
6274
// TODO (v8): Remove this in v8
6375
/**
6476
* @deprecated Moved to constructor options of the `Http` and `Undici` integration.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import type { Envelope, EventEnvelope } from '@sentry/types';
2+
import { createEnvelope, logger } from '@sentry/utils';
3+
import * as http from 'http';
4+
5+
import { NodeClient } from '../../src';
6+
import { Spotlight } from '../../src/integrations';
7+
import { getDefaultNodeClientOptions } from '../helper/node-client-options';
8+
9+
describe('Spotlight', () => {
10+
const loggerSpy = jest.spyOn(logger, 'warn');
11+
12+
afterEach(() => {
13+
loggerSpy.mockClear();
14+
jest.clearAllMocks();
15+
});
16+
17+
const options = getDefaultNodeClientOptions();
18+
const client = new NodeClient(options);
19+
20+
it('has a name and id', () => {
21+
const integration = new Spotlight();
22+
expect(integration.name).toEqual('Spotlight');
23+
expect(Spotlight.id).toEqual('Spotlight');
24+
});
25+
26+
it('registers a callback on the `beforeEnvelope` hook', () => {
27+
const clientWithSpy = {
28+
...client,
29+
on: jest.fn(),
30+
};
31+
const integration = new Spotlight();
32+
// @ts-expect-error - this is fine in tests
33+
integration.setup(clientWithSpy);
34+
expect(clientWithSpy.on).toHaveBeenCalledWith('beforeEnvelope', expect.any(Function));
35+
});
36+
37+
it('sends an envelope POST request to the sidecar url', () => {
38+
const httpSpy = jest.spyOn(http, 'request').mockImplementationOnce(() => {
39+
return {
40+
on: jest.fn(),
41+
write: jest.fn(),
42+
end: jest.fn(),
43+
} as any;
44+
});
45+
46+
let callback: (envelope: Envelope) => void = () => {};
47+
const clientWithSpy = {
48+
...client,
49+
on: jest.fn().mockImplementationOnce((_, cb) => (callback = cb)),
50+
};
51+
52+
const integration = new Spotlight();
53+
// @ts-expect-error - this is fine in tests
54+
integration.setup(clientWithSpy);
55+
56+
const envelope = createEnvelope<EventEnvelope>({ event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2', sent_at: '123' }, [
57+
[{ type: 'event' }, { event_id: 'aa3ff046696b4bc6b609ce6d28fde9e2' }],
58+
]);
59+
60+
callback(envelope);
61+
62+
expect(httpSpy).toHaveBeenCalledWith(
63+
{
64+
headers: {
65+
'Content-Type': 'application/x-sentry-envelope',
66+
},
67+
hostname: 'localhost',
68+
method: 'POST',
69+
path: '/stream',
70+
port: '8969',
71+
},
72+
expect.any(Function),
73+
);
74+
});
75+
76+
describe('no-ops if', () => {
77+
it('an invalid URL is passed', () => {
78+
const integration = new Spotlight({ sidecarUrl: 'invalid-url' });
79+
integration.setup(client);
80+
expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid sidecar URL: invalid-url'));
81+
});
82+
83+
it("the client doesn't support life cycle hooks", () => {
84+
const integration = new Spotlight({ sidecarUrl: 'http://mylocalhost:8969' });
85+
const clientWithoutHooks = { ...client };
86+
// @ts-expect-error - this is fine in tests
87+
delete client.on;
88+
// @ts-expect-error - this is fine in tests
89+
integration.setup(clientWithoutHooks);
90+
expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining(' missing method on SDK client (`client.on`)'));
91+
});
92+
});
93+
94+
it('warns if the NODE_ENV variable doesn\'t equal "development"', () => {
95+
const oldEnvValue = process.env.NODE_ENV;
96+
process.env.NODE_ENV = 'production';
97+
98+
const integration = new Spotlight({ sidecarUrl: 'http://localhost:8969' });
99+
integration.setup(client);
100+
101+
expect(loggerSpy).toHaveBeenCalledWith(
102+
expect.stringContaining("It seems you're not in dev mode. Do you really want to have Spoltight enabled?"),
103+
);
104+
105+
process.env.NODE_ENV = oldEnvValue;
106+
});
107+
108+
it('doesn\'t warn if the NODE_ENV variable equals "development"', () => {
109+
const oldEnvValue = process.env.NODE_ENV;
110+
process.env.NODE_ENV = 'development';
111+
112+
const integration = new Spotlight({ sidecarUrl: 'http://localhost:8969' });
113+
integration.setup(client);
114+
115+
expect(loggerSpy).not.toHaveBeenCalledWith(
116+
expect.stringContaining("It seems you're not in dev mode. Do you really want to have Spoltight enabled?"),
117+
);
118+
119+
process.env.NODE_ENV = oldEnvValue;
120+
});
121+
});

0 commit comments

Comments
 (0)