Skip to content

Commit

Permalink
feat: bring in hook related logic
Browse files Browse the repository at this point in the history
  • Loading branch information
JonasBa committed Jun 21, 2023
1 parent 937cbd0 commit bc043ee
Show file tree
Hide file tree
Showing 5 changed files with 411 additions and 177 deletions.
180 changes: 117 additions & 63 deletions packages/browser/src/profiling/hubextensions.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,135 @@
import { getCurrentHub, getMainCarrier } from '@sentry/core';
import type { CustomSamplingContext, Hub, Transaction, TransactionContext } from '@sentry/types';
import { getCurrentHub } from '@sentry/core';
import type { CustomSamplingContext, Transaction } from '@sentry/types';
import { logger, uuid4 } from '@sentry/utils';

import type { BrowserClient } from '../client';
import { WINDOW } from '../helpers';
import type {
JSSelfProfile,
JSSelfProfiler,
JSSelfProfilerConstructor,
ProcessedJSSelfProfile,
} from './jsSelfProfiling';
import { sendProfile } from './sendProfile';
import { isValidSampleRate } from './utils';

// Max profile duration.
const MAX_PROFILE_DURATION_MS = 30_000;
export const MAX_PROFILE_DURATION_MS = 30_000;
// Keep a flag value to avoid re-initializing the profiler constructor. If it fails
// once, it will always fail and this allows us to early return.
let PROFILING_CONSTRUCTOR_FAILED = false;

// While we experiment, per transaction sampling interval will be more flexible to work with.
type StartTransaction = (
this: Hub,
transactionContext: TransactionContext,
// Takes a transaction and determines if it should be profiled or not. If it should be profiled, it returns the
// profile_id, otherwise returns undefined. Takes care of setting profile context on transaction as well
/**
*
*/
export function maybeProfileTransaction(
client: BrowserClient,
transaction: Transaction,
customSamplingContext?: CustomSamplingContext,
) => Transaction | undefined;
): string | undefined {
// profilesSampleRate is multiplied with tracesSampleRate to get the final sampling rate. We dont perform
// the actual multiplication to get the final rate, but we discard the profile if the transaction was sampled,
// so anything after this block from here is based on the transaction sampling.
if (!transaction.sampled) {
return;
}

// Client and options are required for profiling
if (!client) {
__DEBUG_BUILD__ && logger.log('[Profiling] Profiling disabled, no client found.');
return;
}

const options = client.getOptions();
if (!options) {
__DEBUG_BUILD__ && logger.log('[Profiling] Profiling disabled, no options found.');
return;
}

// @ts-ignore profilesSampler is not part of the browser options yet
const profilesSampler = options.profilesSampler;
// @ts-ignore profilesSampleRate is not part of the browser options yet
let profilesSampleRate: number | boolean | undefined = options.profilesSampleRate;

// Prefer sampler to sample rate if both are provided.
if (typeof profilesSampler === 'function') {
profilesSampleRate = profilesSampler({ transactionContext: transaction.toContext(), ...customSamplingContext });
}

// Since this is coming from the user (or from a function provided by the user), who knows what we might get. (The
// only valid values are booleans or numbers between 0 and 1.)
if (!isValidSampleRate(profilesSampleRate)) {
__DEBUG_BUILD__ && logger.warn('[Profiling] Discarding profile because of invalid sample rate.');
return;
}

// if the function returned 0 (or false), or if `profileSampleRate` is 0, it's a sign the profile should be dropped
if (!profilesSampleRate) {
__DEBUG_BUILD__ &&
logger.log(
`[Profiling] Discarding profile because ${
typeof profilesSampler === 'function'
? 'profileSampler returned 0 or false'
: 'a negative sampling decision was inherited or profileSampleRate is set to 0'
}`,
);
return;
}

// Now we roll the dice. Math.random is inclusive of 0, but not of 1, so strict < is safe here. In case sampleRate is
// a boolean, the < comparison will cause it to be automatically cast to 1 if it's true and 0 if it's false.
const sampled = profilesSampleRate === true ? true : Math.random() < profilesSampleRate;
// Check if we should sample this profile
if (!sampled) {
__DEBUG_BUILD__ &&
logger.log(
`[Profiling] Discarding profile because it's not included in the random sample (sampling rate = ${Number(
profilesSampleRate,
)})`,
);
return;
}

const profile_id = uuid4();
CpuProfilerBindings.startProfiling(profile_id);

__DEBUG_BUILD__ && logger.log(`[Profiling] started profiling transaction: ${transaction.name}`);

// set transaction context - do this regardless if profiling fails down the line
// so that we can still see the profile_id in the transaction context
return profile_id;
}

/**
*
*/
export function stopTransactionProfile(
transaction: Transaction,
profile_id: string | undefined,
): ReturnType<(typeof CpuProfilerBindings)['stopProfiling']> | null {
// Should not happen, but satisfy the type checker and be safe regardless.
if (!profile_id) {
return null;
}

const profile = CpuProfilerBindings.stopProfiling(profile_id);

__DEBUG_BUILD__ && logger.log(`[Profiling] stopped profiling of transaction: ${transaction.name}`);

// In case of an overlapping transaction, stopProfiling may return null and silently ignore the overlapping profile.
if (!profile) {
__DEBUG_BUILD__ &&
logger.log(
`[Profiling] profiler returned null profile for: ${transaction.name}`,
'this may indicate an overlapping transaction or a call to stopProfiling with a profile title that was never started',
);
return null;
}

// Assign profile_id to the profile
profile.profile_id = profile_id;
return profile;
}

/**
* Check if profiler constructor is available.
Expand Down Expand Up @@ -247,56 +354,3 @@ function wrapTransactionWithProfiling(transaction: Transaction): Transaction {
transaction.finish = profilingWrappedTransactionFinish;
return transaction;
}

/**
* Wraps startTransaction with profiling logic. This is done automatically by the profiling integration.
*/
function __PRIVATE__wrapStartTransactionWithProfiling(startTransaction: StartTransaction): StartTransaction {
return function wrappedStartTransaction(
this: Hub,
transactionContext: TransactionContext,
customSamplingContext?: CustomSamplingContext,
): Transaction | undefined {
const transaction: Transaction | undefined = startTransaction.call(this, transactionContext, customSamplingContext);
if (transaction === undefined) {
if (__DEBUG_BUILD__) {
logger.log('[Profiling] Transaction is undefined, skipping profiling');
}
return transaction;
}

return wrapTransactionWithProfiling(transaction);
};
}

/**
* Patches startTransaction and stopTransaction with profiling logic.
*/
export function addProfilingExtensionMethods(): void {
const carrier = getMainCarrier();
if (!carrier.__SENTRY__) {
if (__DEBUG_BUILD__) {
logger.log("[Profiling] Can't find main carrier, profiling won't work.");
}
return;
}
carrier.__SENTRY__.extensions = carrier.__SENTRY__.extensions || {};

if (!carrier.__SENTRY__.extensions['startTransaction']) {
if (__DEBUG_BUILD__) {
logger.log(
'[Profiling] startTransaction does not exists, profiling will not work. Make sure you import @sentry/tracing package before @sentry/profiling-node as import order matters.',
);
}
return;
}

if (__DEBUG_BUILD__) {
logger.log('[Profiling] startTransaction exists, patching it with profiling functionality...');
}

carrier.__SENTRY__.extensions['startTransaction'] = __PRIVATE__wrapStartTransactionWithProfiling(
// This is already patched by sentry/tracing, we are going to re-patch it...
carrier.__SENTRY__.extensions['startTransaction'] as StartTransaction,
);
}
134 changes: 123 additions & 11 deletions packages/browser/src/profiling/integration.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,23 @@
import type { Event, EventProcessor, Integration } from '@sentry/types';
import type { BrowserClient } from '@sentry/browser';
import type { Event, EventProcessor, Hub, Integration, Transaction } from '@sentry/types';
import { logger } from '@sentry/utils';

import { PROFILING_EVENT_CACHE } from './cache';
import { addProfilingExtensionMethods } from './hubextensions';
import { MAX_PROFILE_DURATION_MS, maybeProfileTransaction } from './hubextensions';
import { addProfilesToEnvelope, findProfiledTransactionsFromEnvelope } from './utils';

const MAX_PROFILE_QUEUE_LENGTH = 50;
const PROFILE_QUEUE: RawThreadCpuProfile[] = [];
const PROFILE_TIMEOUTS: Record<string, NodeJS.Timeout> = {};

function addToProfileQueue(profile: RawThreadCpuProfile): void {
PROFILE_QUEUE.push(profile);

// We only want to keep the last n profiles in the queue.
if (PROFILE_QUEUE.length > MAX_PROFILE_QUEUE_LENGTH) {
PROFILE_QUEUE.shift();
}
}

/**
* Browser profiling integration. Stores any event that has contexts["profile"]["profile_id"]
Expand All @@ -15,19 +30,116 @@ import { addProfilingExtensionMethods } from './hubextensions';
*/
export class BrowserProfilingIntegration implements Integration {
public readonly name: string = 'BrowserProfilingIntegration';
public getCurrentHub?: () => Hub = undefined;

/**
* @inheritDoc
*/
public setupOnce(addGlobalEventProcessor: (callback: EventProcessor) => void): void {
// Patching the hub to add the extension methods.
// Warning: we have an implicit dependency on import order and we will fail patching if the constructor of
// BrowserProfilingIntegration is called before @sentry/tracing is imported. This is because we need to patch
// the methods of @sentry/tracing which are patched as a side effect of importing @sentry/tracing.
addProfilingExtensionMethods();

// Add our event processor
addGlobalEventProcessor(this.handleGlobalEvent.bind(this));
public setupOnce(addGlobalEventProcessor: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void {
this.getCurrentHub = getCurrentHub;
const client = this.getCurrentHub().getClient() as BrowserClient;

if (client && typeof client.on === 'function') {
client.on('startTransaction', (transaction: Transaction) => {
const profile_id = maybeProfileTransaction(client, transaction, undefined);

if (profile_id) {
const options = client.getOptions();
// Not intended for external use, hence missing types, but we want to profile a couple of things at Sentry that
// currently exceed the default timeout set by the SDKs.
const maxProfileDurationMs =
(options._experiments && options._experiments['maxProfileDurationMs']) || MAX_PROFILE_DURATION_MS;

// Enqueue a timeout to prevent profiles from running over max duration.
if (PROFILE_TIMEOUTS[profile_id]) {
global.clearTimeout(PROFILE_TIMEOUTS[profile_id]);
delete PROFILE_TIMEOUTS[profile_id];
}

PROFILE_TIMEOUTS[profile_id] = global.setTimeout(() => {
__DEBUG_BUILD__ &&
logger.log('[Profiling] max profile duration elapsed, stopping profiling for:', transaction.name);

const profile = stopTransactionProfile(transaction, profile_id);
if (profile) {
addToProfileQueue(profile);
}
}, maxProfileDurationMs);

transaction.setContext('profile', { profile_id });
// @ts-expect-error profile_id is not part of the metadata type
transaction.setMetadata({ profile_id: profile_id });
}
});

client.on('finishTransaction', transaction => {
// @ts-expect-error profile_id is not part of the metadata type
const profile_id = transaction && transaction.metadata && transaction.metadata.profile_id;
if (profile_id) {
if (PROFILE_TIMEOUTS[profile_id]) {
global.clearTimeout(PROFILE_TIMEOUTS[profile_id]);
delete PROFILE_TIMEOUTS[profile_id];
}
const profile = stopTransactionProfile(transaction, profile_id);

if (profile) {
addToProfileQueue(profile);
}
}
});

client.on('beforeEnvelope', (envelope): void => {
// if not profiles are in queue, there is nothing to add to the envelope.
if (!PROFILE_QUEUE.length) {
return;
}

const profiledTransactionEvents = findProfiledTransactionsFromEnvelope(envelope);
if (!profiledTransactionEvents.length) {
return;
}

const profilesToAddToEnvelope: Profile[] = [];

for (const profiledTransaction of profiledTransactionEvents) {
const profile_id = profiledTransaction?.contexts?.['profile']?.['profile_id'];

if (!profile_id) {
throw new TypeError('[Profiling] cannot find profile for a transaction without a profile context');
}

// Remove the profile from the transaction context before sending, relay will take care of the rest.
if (profiledTransaction?.contexts?.['.profile']) {
delete profiledTransaction.contexts.profile;
}

// We need to find both a profile and a transaction event for the same profile_id.
const profileIndex = PROFILE_QUEUE.findIndex(p => p.profile_id === profile_id);
if (profileIndex === -1) {
__DEBUG_BUILD__ && logger.log(`[Profiling] Could not retrieve profile for transaction: ${profile_id}`);
continue;
}

const cpuProfile = PROFILE_QUEUE[profileIndex];
if (!cpuProfile) {
__DEBUG_BUILD__ && logger.log(`[Profiling] Could not retrieve profile for transaction: ${profile_id}`);
continue;
}

// Remove the profile from the queue.
PROFILE_QUEUE.splice(profileIndex, 1);
const profile = createProfilingEvent(cpuProfile, profiledTransaction);

if (profile) {
profilesToAddToEnvelope.push(profile);
}
}

addProfilesToEnvelope(envelope, profilesToAddToEnvelope);
});
} else {
logger.warn('[Profiling] Client does not support hooks, profiling will be disabled');
}
}

/**
Expand Down
10 changes: 2 additions & 8 deletions packages/browser/src/profiling/jsSelfProfiling.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { DebugImage } from '@sentry/types';
// Type definitions for https://wicg.github.io/js-self-profiling/
type JSSelfProfileSampleMarker = 'script' | 'gc' | 'style' | 'layout' | 'paint' | 'other';

Expand Down Expand Up @@ -95,14 +96,7 @@ export interface SentryProfile {
platform: string;
profile: ThreadCpuProfile;
debug_meta?: {
images: {
debug_id: string;
image_addr: string;
code_file: string;
type: string;
image_size: number;
image_vmaddr: string;
}[];
images: DebugImage[];
};
transactions: {
name: string;
Expand Down
Loading

0 comments on commit bc043ee

Please sign in to comment.