-
-
Notifications
You must be signed in to change notification settings - Fork 1.5k
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
Changes from all commits
7b4a79b
7ec688f
06e427a
760bf80
ea7c8ed
ad44031
9cf1ea8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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'); | ||
}, | ||
); | ||
Comment on lines
+82
to
+92
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -28,6 +28,7 @@ import { | |
OnUncaughtException, | ||
OnUnhandledRejection, | ||
RequestData, | ||
Spotlight, | ||
Undici, | ||
} from './integrations'; | ||
import { getModuleFromFilename } from './module'; | ||
|
@@ -179,6 +180,17 @@ export function init(options: NodeOptions = {}): void { | |
} | ||
|
||
updateScopeFromEnvVariables(); | ||
|
||
if (options.spotlight) { | ||
const client = getCurrentHub().getClient(); | ||
if (client && client.addIntegration) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Discussed this offline quickly: Checking this is not easy outside the |
||
// 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 }), | ||
); | ||
} | ||
} | ||
} | ||
|
||
/** | ||
|
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; | ||
}); | ||
}); |
There was a problem hiding this comment.
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?There was a problem hiding this comment.
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
.There was a problem hiding this comment.
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)