Skip to content

Commit 01165db

Browse files
JonasBalforst
andauthored
feat(profiling): Expose profiler as top level primitive (getsentry#13512)
We are about to enter a public beta for continuous profiling, which means it is time to expose this from under the wraps of the integration and align it with how the profiler is exposed in python and iOS SDKs --------- Co-authored-by: Luca Forstner <[email protected]>
1 parent f452423 commit 01165db

File tree

12 files changed

+153
-25
lines changed

12 files changed

+153
-25
lines changed

Diff for: packages/astro/src/index.server.ts

+1
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ export {
123123
withMonitor,
124124
withScope,
125125
zodErrorsIntegration,
126+
profiler,
126127
} from '@sentry/node';
127128

128129
export { init } from './server/sdk';

Diff for: packages/aws-serverless/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ export {
107107
trpcMiddleware,
108108
addOpenTelemetryInstrumentation,
109109
zodErrorsIntegration,
110+
profiler,
110111
} from '@sentry/node';
111112

112113
export {

Diff for: packages/bun/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ export {
128128
trpcMiddleware,
129129
addOpenTelemetryInstrumentation,
130130
zodErrorsIntegration,
131+
profiler,
131132
} from '@sentry/node';
132133

133134
export {

Diff for: packages/core/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ export { sessionTimingIntegration } from './integrations/sessiontiming';
100100
export { zodErrorsIntegration } from './integrations/zoderrors';
101101
export { thirdPartyErrorFilterIntegration } from './integrations/third-party-errors-filter';
102102
export { metrics } from './metrics/exports';
103+
export { profiler } from './profiling';
103104
export type { MetricData } from '@sentry/types';
104105
export { metricsDefault } from './metrics/exports-default';
105106
export { BrowserMetricsAggregator } from './metrics/browser-aggregator';

Diff for: packages/core/src/profiling.ts

+72
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import type { Profiler, ProfilingIntegration } from '@sentry/types';
2+
import { logger } from '@sentry/utils';
3+
4+
import { getClient } from './currentScopes';
5+
import { DEBUG_BUILD } from './debug-build';
6+
7+
function isProfilingIntegrationWithProfiler(
8+
integration: ProfilingIntegration<any> | undefined,
9+
): integration is ProfilingIntegration<any> {
10+
return (
11+
!!integration &&
12+
typeof integration['_profiler'] !== 'undefined' &&
13+
typeof integration['_profiler']['start'] === 'function' &&
14+
typeof integration['_profiler']['stop'] === 'function'
15+
);
16+
}
17+
/**
18+
* Starts the Sentry continuous profiler.
19+
* This mode is exclusive with the transaction profiler and will only work if the profilesSampleRate is set to a falsy value.
20+
* In continuous profiling mode, the profiler will keep reporting profile chunks to Sentry until it is stopped, which allows for continuous profiling of the application.
21+
*/
22+
function startProfiler(): void {
23+
const client = getClient();
24+
if (!client) {
25+
DEBUG_BUILD && logger.warn('No Sentry client available, profiling is not started');
26+
return;
27+
}
28+
29+
const integration = client.getIntegrationByName<ProfilingIntegration<any>>('ProfilingIntegration');
30+
31+
if (!integration) {
32+
DEBUG_BUILD && logger.warn('ProfilingIntegration is not available');
33+
return;
34+
}
35+
36+
if (!isProfilingIntegrationWithProfiler(integration)) {
37+
DEBUG_BUILD && logger.warn('Profiler is not available on profiling integration.');
38+
return;
39+
}
40+
41+
integration._profiler.start();
42+
}
43+
44+
/**
45+
* Stops the Sentry continuous profiler.
46+
* Calls to stop will stop the profiler and flush the currently collected profile data to Sentry.
47+
*/
48+
function stopProfiler(): void {
49+
const client = getClient();
50+
if (!client) {
51+
DEBUG_BUILD && logger.warn('No Sentry client available, profiling is not started');
52+
return;
53+
}
54+
55+
const integration = client.getIntegrationByName<ProfilingIntegration<any>>('ProfilingIntegration');
56+
if (!integration) {
57+
DEBUG_BUILD && logger.warn('ProfilingIntegration is not available');
58+
return;
59+
}
60+
61+
if (!isProfilingIntegrationWithProfiler(integration)) {
62+
DEBUG_BUILD && logger.warn('Profiler is not available on profiling integration.');
63+
return;
64+
}
65+
66+
integration._profiler.stop();
67+
}
68+
69+
export const profiler: Profiler = {
70+
startProfiler,
71+
stopProfiler,
72+
};

Diff for: packages/google-cloud-serverless/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ export {
107107
trpcMiddleware,
108108
addOpenTelemetryInstrumentation,
109109
zodErrorsIntegration,
110+
profiler,
110111
} from '@sentry/node';
111112

112113
export {

Diff for: packages/node/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ export {
127127
spanToBaggageHeader,
128128
trpcMiddleware,
129129
zodErrorsIntegration,
130+
profiler,
130131
} from '@sentry/core';
131132

132133
export type {

Diff for: packages/profiling-node/src/integration.ts

+7-7
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
spanToJSON,
1010
} from '@sentry/core';
1111
import type { NodeClient } from '@sentry/node';
12-
import type { Event, Integration, IntegrationFn, Profile, ProfileChunk, Span } from '@sentry/types';
12+
import type { Event, IntegrationFn, Profile, ProfileChunk, ProfilingIntegration, Span } from '@sentry/types';
1313

1414
import { LRUMap, logger, uuid4 } from '@sentry/utils';
1515

@@ -159,6 +159,7 @@ interface ChunkData {
159159
timer: NodeJS.Timeout | undefined;
160160
startTraceID: string;
161161
}
162+
162163
class ContinuousProfiler {
163164
private _profilerId = uuid4();
164165
private _client: NodeClient | undefined = undefined;
@@ -384,12 +385,8 @@ class ContinuousProfiler {
384385
}
385386
}
386387

387-
export interface ProfilingIntegration extends Integration {
388-
_profiler: ContinuousProfiler;
389-
}
390-
391388
/** Exported only for tests. */
392-
export const _nodeProfilingIntegration = ((): ProfilingIntegration => {
389+
export const _nodeProfilingIntegration = ((): ProfilingIntegration<NodeClient> => {
393390
if (DEBUG_BUILD && ![16, 18, 20, 22].includes(NODE_MAJOR)) {
394391
logger.warn(
395392
`[Profiling] You are using a Node.js version that does not have prebuilt binaries (${NODE_VERSION}).`,
@@ -407,7 +404,10 @@ export const _nodeProfilingIntegration = ((): ProfilingIntegration => {
407404
const options = client.getOptions();
408405

409406
const mode =
410-
(options.profilesSampleRate === undefined || options.profilesSampleRate === 0) && !options.profilesSampler
407+
(options.profilesSampleRate === undefined ||
408+
options.profilesSampleRate === null ||
409+
options.profilesSampleRate === 0) &&
410+
!options.profilesSampler
411411
? 'continuous'
412412
: 'span';
413413
switch (mode) {

Diff for: packages/profiling-node/test/spanProfileUtils.test.ts

+47-16
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@ import * as Sentry from '@sentry/node';
22

33
import { getMainCarrier } from '@sentry/core';
44
import type { NodeClientOptions } from '@sentry/node/build/types/types';
5+
import type { ProfilingIntegration } from '@sentry/types';
56
import type { ProfileChunk, Transport } from '@sentry/types';
67
import { GLOBAL_OBJ, createEnvelope, logger } from '@sentry/utils';
78
import { CpuProfilerBindings } from '../src/cpu_profiler';
8-
import { type ProfilingIntegration, _nodeProfilingIntegration } from '../src/integration';
9+
import { _nodeProfilingIntegration } from '../src/integration';
910

1011
function makeClientWithHooks(): [Sentry.NodeClient, Transport] {
1112
const integration = _nodeProfilingIntegration();
@@ -299,7 +300,7 @@ describe('automated span instrumentation', () => {
299300
Sentry.setCurrentClient(client);
300301
client.init();
301302

302-
const integration = client.getIntegrationByName<ProfilingIntegration>('ProfilingIntegration');
303+
const integration = client.getIntegrationByName<ProfilingIntegration<Sentry.NodeClient>>('ProfilingIntegration');
303304
if (!integration) {
304305
throw new Error('Profiling integration not found');
305306
}
@@ -390,7 +391,7 @@ describe('continuous profiling', () => {
390391
});
391392
afterEach(() => {
392393
const client = Sentry.getClient();
393-
const integration = client?.getIntegrationByName<ProfilingIntegration>('ProfilingIntegration');
394+
const integration = client?.getIntegrationByName<ProfilingIntegration<Sentry.NodeClient>>('ProfilingIntegration');
394395

395396
if (integration) {
396397
integration._profiler.stop();
@@ -432,7 +433,7 @@ describe('continuous profiling', () => {
432433

433434
const transportSpy = jest.spyOn(transport, 'send').mockReturnValue(Promise.resolve({}));
434435

435-
const integration = client.getIntegrationByName<ProfilingIntegration>('ProfilingIntegration');
436+
const integration = client.getIntegrationByName<ProfilingIntegration<Sentry.NodeClient>>('ProfilingIntegration');
436437
if (!integration) {
437438
throw new Error('Profiling integration not found');
438439
}
@@ -446,7 +447,7 @@ describe('continuous profiling', () => {
446447
expect(profile.client_sdk.version).toEqual(expect.stringMatching(/\d+\.\d+\.\d+/));
447448
});
448449

449-
it('initializes the continuous profiler and binds the sentry client', () => {
450+
it('initializes the continuous profiler', () => {
450451
const startProfilingSpy = jest.spyOn(CpuProfilerBindings, 'startProfiling');
451452

452453
const [client] = makeContinuousProfilingClient();
@@ -455,14 +456,13 @@ describe('continuous profiling', () => {
455456

456457
expect(startProfilingSpy).not.toHaveBeenCalledTimes(1);
457458

458-
const integration = client.getIntegrationByName<ProfilingIntegration>('ProfilingIntegration');
459+
const integration = client.getIntegrationByName<ProfilingIntegration<Sentry.NodeClient>>('ProfilingIntegration');
459460
if (!integration) {
460461
throw new Error('Profiling integration not found');
461462
}
462463
integration._profiler.start();
463464

464465
expect(integration._profiler).toBeDefined();
465-
expect(integration._profiler['_client']).toBe(client);
466466
});
467467

468468
it('starts a continuous profile', () => {
@@ -473,7 +473,7 @@ describe('continuous profiling', () => {
473473
client.init();
474474

475475
expect(startProfilingSpy).not.toHaveBeenCalledTimes(1);
476-
const integration = client.getIntegrationByName<ProfilingIntegration>('ProfilingIntegration');
476+
const integration = client.getIntegrationByName<ProfilingIntegration<Sentry.NodeClient>>('ProfilingIntegration');
477477
if (!integration) {
478478
throw new Error('Profiling integration not found');
479479
}
@@ -490,7 +490,7 @@ describe('continuous profiling', () => {
490490
client.init();
491491

492492
expect(startProfilingSpy).not.toHaveBeenCalledTimes(1);
493-
const integration = client.getIntegrationByName<ProfilingIntegration>('ProfilingIntegration');
493+
const integration = client.getIntegrationByName<ProfilingIntegration<Sentry.NodeClient>>('ProfilingIntegration');
494494
if (!integration) {
495495
throw new Error('Profiling integration not found');
496496
}
@@ -509,7 +509,7 @@ describe('continuous profiling', () => {
509509
client.init();
510510

511511
expect(startProfilingSpy).not.toHaveBeenCalledTimes(1);
512-
const integration = client.getIntegrationByName<ProfilingIntegration>('ProfilingIntegration');
512+
const integration = client.getIntegrationByName<ProfilingIntegration<Sentry.NodeClient>>('ProfilingIntegration');
513513
if (!integration) {
514514
throw new Error('Profiling integration not found');
515515
}
@@ -529,7 +529,7 @@ describe('continuous profiling', () => {
529529
client.init();
530530

531531
expect(startProfilingSpy).not.toHaveBeenCalledTimes(1);
532-
const integration = client.getIntegrationByName<ProfilingIntegration>('ProfilingIntegration');
532+
const integration = client.getIntegrationByName<ProfilingIntegration<Sentry.NodeClient>>('ProfilingIntegration');
533533
if (!integration) {
534534
throw new Error('Profiling integration not found');
535535
}
@@ -548,7 +548,7 @@ describe('continuous profiling', () => {
548548
client.init();
549549

550550
expect(startProfilingSpy).not.toHaveBeenCalledTimes(1);
551-
const integration = client.getIntegrationByName<ProfilingIntegration>('ProfilingIntegration');
551+
const integration = client.getIntegrationByName<ProfilingIntegration<Sentry.NodeClient>>('ProfilingIntegration');
552552
if (!integration) {
553553
throw new Error('Profiling integration not found');
554554
}
@@ -604,7 +604,7 @@ describe('continuous profiling', () => {
604604

605605
const transportSpy = jest.spyOn(transport, 'send').mockReturnValue(Promise.resolve({}));
606606

607-
const integration = client.getIntegrationByName<ProfilingIntegration>('ProfilingIntegration');
607+
const integration = client.getIntegrationByName<ProfilingIntegration<Sentry.NodeClient>>('ProfilingIntegration');
608608
if (!integration) {
609609
throw new Error('Profiling integration not found');
610610
}
@@ -632,7 +632,7 @@ describe('continuous profiling', () => {
632632
},
633633
});
634634

635-
const integration = client.getIntegrationByName<ProfilingIntegration>('ProfilingIntegration');
635+
const integration = client.getIntegrationByName<ProfilingIntegration<Sentry.NodeClient>>('ProfilingIntegration');
636636
if (!integration) {
637637
throw new Error('Profiling integration not found');
638638
}
@@ -692,7 +692,7 @@ describe('span profiling mode', () => {
692692
Sentry.startInactiveSpan({ forceTransaction: true, name: 'profile_hub' });
693693

694694
expect(startProfilingSpy).toHaveBeenCalled();
695-
const integration = client.getIntegrationByName<ProfilingIntegration>('ProfilingIntegration');
695+
const integration = client.getIntegrationByName<ProfilingIntegration<Sentry.NodeClient>>('ProfilingIntegration');
696696

697697
if (!integration) {
698698
throw new Error('Profiling integration not found');
@@ -703,6 +703,10 @@ describe('span profiling mode', () => {
703703
});
704704
});
705705
describe('continuous profiling mode', () => {
706+
beforeEach(() => {
707+
jest.clearAllMocks();
708+
});
709+
706710
it.each([
707711
['profilesSampleRate=0', makeClientOptions({ profilesSampleRate: 0 })],
708712
['profilesSampleRate=undefined', makeClientOptions({ profilesSampleRate: undefined })],
@@ -739,7 +743,7 @@ describe('continuous profiling mode', () => {
739743

740744
jest.spyOn(transport, 'send').mockReturnValue(Promise.resolve({}));
741745

742-
const integration = client.getIntegrationByName<ProfilingIntegration>('ProfilingIntegration');
746+
const integration = client.getIntegrationByName<ProfilingIntegration<Sentry.NodeClient>>('ProfilingIntegration');
743747
if (!integration) {
744748
throw new Error('Profiling integration not found');
745749
}
@@ -750,4 +754,31 @@ describe('continuous profiling mode', () => {
750754
Sentry.startInactiveSpan({ forceTransaction: true, name: 'profile_hub' });
751755
expect(startProfilingSpy).toHaveBeenCalledTimes(callCount);
752756
});
757+
758+
it('top level methods proxy to integration', () => {
759+
const client = new Sentry.NodeClient({
760+
...makeClientOptions({ profilesSampleRate: undefined }),
761+
dsn: 'https://[email protected]/6625302',
762+
tracesSampleRate: 1,
763+
transport: _opts =>
764+
Sentry.makeNodeTransport({
765+
url: 'https://[email protected]/6625302',
766+
recordDroppedEvent: () => {
767+
return undefined;
768+
},
769+
}),
770+
integrations: [_nodeProfilingIntegration()],
771+
});
772+
773+
Sentry.setCurrentClient(client);
774+
client.init();
775+
776+
const startProfilingSpy = jest.spyOn(CpuProfilerBindings, 'startProfiling');
777+
const stopProfilingSpy = jest.spyOn(CpuProfilerBindings, 'stopProfiling');
778+
779+
Sentry.profiler.startProfiler();
780+
expect(startProfilingSpy).toHaveBeenCalledTimes(1);
781+
Sentry.profiler.stopProfiler();
782+
expect(stopProfilingSpy).toHaveBeenCalledTimes(1);
783+
});
753784
});

Diff for: packages/profiling-node/test/spanProfileUtils.worker.test.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ jest.setTimeout(10000);
99

1010
import * as Sentry from '@sentry/node';
1111
import type { Transport } from '@sentry/types';
12-
import { type ProfilingIntegration, _nodeProfilingIntegration } from '../src/integration';
12+
import { type ProfilingIntegration } from '@sentry/types';
13+
import { _nodeProfilingIntegration } from '../src/integration';
1314

1415
function makeContinuousProfilingClient(): [Sentry.NodeClient, Transport] {
1516
const integration = _nodeProfilingIntegration();
@@ -49,7 +50,7 @@ it('worker threads context', () => {
4950
},
5051
});
5152

52-
const integration = client.getIntegrationByName<ProfilingIntegration>('ProfilingIntegration');
53+
const integration = client.getIntegrationByName<ProfilingIntegration<any>>('ProfilingIntegration');
5354
if (!integration) {
5455
throw new Error('Profiling integration not found');
5556
}

Diff for: packages/types/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -171,4 +171,5 @@ export type {
171171
Metrics,
172172
} from './metrics';
173173
export type { ParameterizedString } from './parameterize';
174+
export type { ContinuousProfiler, ProfilingIntegration, Profiler } from './profiling';
174175
export type { ViewHierarchyData, ViewHierarchyWindow } from './view-hierarchy';

0 commit comments

Comments
 (0)