diff --git a/packages/profiling-node/bindings/cpu_profiler.cc b/packages/profiling-node/bindings/cpu_profiler.cc index 6f9651bcd6a4..cbff754623bc 100644 --- a/packages/profiling-node/bindings/cpu_profiler.cc +++ b/packages/profiling-node/bindings/cpu_profiler.cc @@ -10,6 +10,7 @@ #include #include +#include #include #include #include @@ -23,6 +24,10 @@ static const v8::CpuProfilingNamingMode static const v8::CpuProfilingLoggingMode kDefaultLoggingMode(v8::CpuProfilingLoggingMode::kEagerLogging); +enum ProfileFormat { + kFormatThread = 0, + kFormatChunk = 1, +}; // Allow users to override the default logging mode via env variable. This is // useful because sometimes the flow of the profiled program can be to execute // many sequential transaction - in that case, it may be preferable to set eager @@ -50,6 +55,12 @@ v8::CpuProfilingLoggingMode GetLoggingMode() { return kDefaultLoggingMode; } +uint64_t timestamp_milliseconds() { + return std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); +} + class SentryProfile; class Profiler; @@ -241,6 +252,7 @@ class Profiler { class SentryProfile { private: uint64_t started_at; + uint64_t timestamp; uint16_t heap_write_index = 0; uint16_t cpu_write_index = 0; @@ -258,7 +270,7 @@ class SentryProfile { public: explicit SentryProfile(const char *id) - : started_at(uv_hrtime()), + : started_at(uv_hrtime()), timestamp(timestamp_milliseconds()), memory_sampler_cb([this](uint64_t ts, v8::HeapStatistics &stats) { if ((heap_write_index >= heap_stats_ts.capacity()) || heap_write_index >= heap_stats_usage.capacity()) { @@ -302,6 +314,7 @@ class SentryProfile { const std::vector &cpu_usage_timestamps() const; const std::vector &cpu_usage_values() const; const uint16_t &cpu_usage_write_index() const; + const uint64_t &profile_start_timestamp() const; void Start(Profiler *profiler); v8::CpuProfile *Stop(Profiler *profiler); @@ -314,6 +327,7 @@ void SentryProfile::Start(Profiler *profiler) { .ToLocalChecked(); started_at = uv_hrtime(); + timestamp = timestamp_milliseconds(); // Initialize the CPU Profiler profiler->cpu_profiler->StartProfiling( @@ -368,6 +382,9 @@ const std::vector &SentryProfile::cpu_usage_values() const { const uint16_t &SentryProfile::cpu_usage_write_index() const { return cpu_write_index; }; +const uint64_t &SentryProfile::profile_start_timestamp() const { + return timestamp; +} static void CleanupSentryProfile(Profiler *profiler, SentryProfile *sentry_profile, @@ -528,8 +545,9 @@ CreateFrameNode(const napi_env &env, const v8::CpuProfileNode &node, return js_node; }; -napi_value CreateSample(const napi_env &env, const uint32_t stack_id, - const int64_t sample_timestamp_us, +napi_value CreateSample(const napi_env &env, const enum ProfileFormat format, + const uint32_t stack_id, const int64_t sample_timestamp, + const double chunk_timestamp, const uint32_t thread_id) { napi_value js_node; napi_create_object(env, &js_node); @@ -543,11 +561,20 @@ napi_value CreateSample(const napi_env &env, const uint32_t stack_id, NAPI_AUTO_LENGTH, &thread_id_prop); napi_set_named_property(env, js_node, "thread_id", thread_id_prop); - napi_value elapsed_since_start_ns_prop; - napi_create_int64(env, sample_timestamp_us * 1000, - &elapsed_since_start_ns_prop); - napi_set_named_property(env, js_node, "elapsed_since_start_ns", - elapsed_since_start_ns_prop); + switch (format) { + case ProfileFormat::kFormatThread: { + napi_value timestamp; + napi_create_int64(env, sample_timestamp, ×tamp); + napi_set_named_property(env, js_node, "elapsed_since_start_ns", timestamp); + } break; + case ProfileFormat::kFormatChunk: { + napi_value timestamp; + napi_create_double(env, chunk_timestamp, ×tamp); + napi_set_named_property(env, js_node, "timestamp", timestamp); + } break; + default: + break; + } return js_node; }; @@ -566,11 +593,13 @@ std::string hashCpuProfilerNodeByPath(const v8::CpuProfileNode *node, } static void GetSamples(const napi_env &env, const v8::CpuProfile *profile, + ProfileFormat format, + const uint64_t profile_start_timestamp_ms, const uint32_t thread_id, napi_value &samples, napi_value &stacks, napi_value &frames, napi_value &resources) { const int64_t profile_start_time_us = profile->GetStartTime(); - const int sampleCount = profile->GetSamplesCount(); + const int64_t sampleCount = profile->GetSamplesCount(); uint32_t unique_stack_id = 0; uint32_t unique_frame_id = 0; @@ -590,7 +619,7 @@ static void GetSamples(const napi_env &env, const v8::CpuProfile *profile, uint32_t stack_index = unique_stack_id; const v8::CpuProfileNode *node = profile->GetSample(i); - const int64_t sample_timestamp = profile->GetSampleTimestamp(i); + const int64_t sample_timestamp_us = profile->GetSampleTimestamp(i); // If a node was only on top of the stack once, then it will only ever // be inserted once and there is no need for hashing. @@ -609,8 +638,16 @@ static void GetSamples(const napi_env &env, const v8::CpuProfile *profile, } } - napi_value sample = CreateSample( - env, stack_index, sample_timestamp - profile_start_time_us, thread_id); + uint64_t sample_delta_us = sample_timestamp_us - profile_start_time_us; + uint64_t sample_timestamp_ns = sample_delta_us * 1e3; + uint64_t sample_offset_from_profile_start_ms = + (sample_timestamp_us - profile_start_time_us) * 1e-3; + double seconds_since_start = + profile_start_timestamp_ms + sample_offset_from_profile_start_ms; + + napi_value sample = nullptr; + sample = CreateSample(env, format, stack_index, sample_timestamp_ns, + seconds_since_start, thread_id); if (stack_index != unique_stack_id) { napi_value index; @@ -671,19 +708,19 @@ static void GetSamples(const napi_env &env, const v8::CpuProfile *profile, } } -static napi_value -TranslateMeasurementsDouble(const napi_env &env, const char *unit, - const uint16_t size, - const std::vector &values, - const std::vector ×tamps) { - if (size > values.size() || size > timestamps.size()) { +static napi_value TranslateMeasurementsDouble( + const napi_env &env, const enum ProfileFormat format, const char *unit, + const uint64_t profile_start_timestamp_ms, const uint16_t size, + const std::vector &values, + const std::vector ×tamps_ns) { + if (size > values.size() || size > timestamps_ns.size()) { napi_throw_range_error(env, "NAPI_ERROR", "CPU measurement size is larger than the number of " "values or timestamps"); return nullptr; } - if (values.size() != timestamps.size()) { + if (values.size() != timestamps_ns.size()) { napi_throw_range_error(env, "NAPI_ERROR", "CPU measurement entries are corrupt, expected " "values and timestamps to be of equal length"); @@ -713,11 +750,19 @@ TranslateMeasurementsDouble(const napi_env &env, const char *unit, } } - napi_value ts; - napi_create_int64(env, timestamps[i], &ts); - napi_set_named_property(env, entry, "value", value); - napi_set_named_property(env, entry, "elapsed_since_start_ns", ts); + + if (format == ProfileFormat::kFormatThread) { + napi_value ts; + napi_create_int64(env, timestamps_ns[i], &ts); + napi_set_named_property(env, entry, "elapsed_since_start_ns", ts); + } else if (format == ProfileFormat::kFormatChunk) { + napi_value ts; + napi_create_double( + env, profile_start_timestamp_ms + (timestamps_ns[i] * 1e-6), &ts); + napi_set_named_property(env, entry, "timestamp", ts); + } + napi_set_element(env, values_array, i, entry); } @@ -727,17 +772,19 @@ TranslateMeasurementsDouble(const napi_env &env, const char *unit, } static napi_value -TranslateMeasurements(const napi_env &env, const char *unit, +TranslateMeasurements(const napi_env &env, const enum ProfileFormat format, + const char *unit, + const uint64_t profile_start_timestamp_ms, const uint16_t size, const std::vector &values, - const std::vector ×tamps) { - if (size > values.size() || size > timestamps.size()) { + const std::vector ×tamps_ns) { + if (size > values.size() || size > timestamps_ns.size()) { napi_throw_range_error(env, "NAPI_ERROR", "Memory measurement size is larger than the number " "of values or timestamps"); return nullptr; } - if (values.size() != timestamps.size()) { + if (values.size() != timestamps_ns.size()) { napi_throw_range_error(env, "NAPI_ERROR", "Memory measurement entries are corrupt, expected " "values and timestamps to be of equal length"); @@ -761,11 +808,22 @@ TranslateMeasurements(const napi_env &env, const char *unit, napi_value value; napi_create_int64(env, values[i], &value); - napi_value ts; - napi_create_int64(env, timestamps[i], &ts); - napi_set_named_property(env, entry, "value", value); - napi_set_named_property(env, entry, "elapsed_since_start_ns", ts); + switch (format) { + case ProfileFormat::kFormatThread: { + napi_value ts; + napi_create_int64(env, timestamps_ns[i], &ts); + napi_set_named_property(env, entry, "elapsed_since_start_ns", ts); + } break; + case ProfileFormat::kFormatChunk: { + napi_value ts; + napi_create_double( + env, profile_start_timestamp_ms + (timestamps_ns[i] * 1e-6), &ts); + napi_set_named_property(env, entry, "timestamp", ts); + } break; + default: + break; + } napi_set_element(env, values_array, i, entry); } @@ -776,6 +834,8 @@ TranslateMeasurements(const napi_env &env, const char *unit, static napi_value TranslateProfile(const napi_env &env, const v8::CpuProfile *profile, + const enum ProfileFormat format, + const uint64_t profile_start_timestamp_ms, const uint32_t thread_id, bool collect_resources) { napi_value js_profile; @@ -805,7 +865,8 @@ static napi_value TranslateProfile(const napi_env &env, napi_set_named_property(env, js_profile, "profiler_logging_mode", logging_mode); - GetSamples(env, profile, thread_id, samples, stacks, frames, resources); + GetSamples(env, profile, format, profile_start_timestamp_ms, thread_id, + samples, stacks, frames, resources); if (collect_resources) { napi_set_named_property(env, js_profile, "resources", resources); @@ -892,14 +953,14 @@ static napi_value StartProfiling(napi_env env, napi_callback_info info) { // StopProfiling(string title) // https://v8docs.nodesource.com/node-18.2/d2/d34/classv8_1_1_cpu_profiler.html#a40ca4c8a8aa4c9233aa2a2706457cc80 static napi_value StopProfiling(napi_env env, napi_callback_info info) { - size_t argc = 3; - napi_value argv[3]; + size_t argc = 4; + napi_value argv[4]; assert(napi_get_cb_info(env, info, &argc, argv, NULL, NULL) == napi_ok); - if (argc < 2) { + if (argc < 3) { napi_throw_error(env, "NAPI_ERROR", - "StopProfiling expects at least two arguments."); + "StopProfiling expects at least three arguments."); napi_value napi_null; assert(napi_get_null(env, &napi_null) == napi_ok); @@ -921,14 +982,17 @@ static napi_value StopProfiling(napi_env env, napi_callback_info info) { return napi_null; } - // Verify the second argument is a number - napi_valuetype callbacktype1; - assert(napi_typeof(env, argv[1], &callbacktype1) == napi_ok); + size_t len; + assert(napi_get_value_string_utf8(env, argv[0], NULL, 0, &len) == napi_ok); - if (callbacktype1 != napi_number) { + char *title = (char *)malloc(len + 1); + assert(napi_get_value_string_utf8(env, argv[0], title, len + 1, &len) == + napi_ok); + + if (len < 1) { napi_throw_error( env, "NAPI_ERROR", - "StopProfiling expects a thread_id integer as second argument."); + "StopProfiling expects a non empty string as first argument."); napi_value napi_null; assert(napi_get_null(env, &napi_null) == napi_ok); @@ -936,16 +1000,13 @@ static napi_value StopProfiling(napi_env env, napi_callback_info info) { return napi_null; } - size_t len; - assert(napi_get_value_string_utf8(env, argv[0], NULL, 0, &len) == napi_ok); - - char *title = (char *)malloc(len + 1); - assert(napi_get_value_string_utf8(env, argv[0], title, len + 1, &len) == - napi_ok); + // Verify the second argument is a number + napi_valuetype callbacktype1; + assert(napi_typeof(env, argv[1], &callbacktype1) == napi_ok); - if (len < 1) { + if (callbacktype1 != napi_number) { napi_throw_error(env, "NAPI_ERROR", - "StopProfiling expects a string as first argument."); + "StopProfiling expects a format type as second argument."); napi_value napi_null; assert(napi_get_null(env, &napi_null) == napi_ok); @@ -953,9 +1014,27 @@ static napi_value StopProfiling(napi_env env, napi_callback_info info) { return napi_null; } + // Verify the second argument is a number + napi_valuetype callbacktype2; + assert(napi_typeof(env, argv[2], &callbacktype2) == napi_ok); + + if (callbacktype2 != napi_number) { + napi_throw_error( + env, "NAPI_ERROR", + "StopProfiling expects a thread_id integer as third argument."); + + napi_value napi_null; + assert(napi_get_null(env, &napi_null) == napi_ok); + return napi_null; + } + + // Get the value of the second argument and convert it to uint8 + int32_t format; + assert(napi_get_value_int32(env, argv[1], &format) == napi_ok); + // Get the value of the second argument and convert it to uint64 int64_t thread_id; - assert(napi_get_value_int64(env, argv[1], &thread_id) == napi_ok); + assert(napi_get_value_int64(env, argv[2], &thread_id) == napi_ok); // Get profiler from instance data Profiler *profiler; @@ -967,7 +1046,6 @@ static napi_value StopProfiling(napi_env env, napi_callback_info info) { napi_value napi_null; assert(napi_get_null(env, &napi_null) == napi_ok); - return napi_null; } @@ -994,23 +1072,39 @@ static napi_value StopProfiling(napi_env env, napi_callback_info info) { }; napi_valuetype callbacktype3; - assert(napi_typeof(env, argv[2], &callbacktype3) == napi_ok); + assert(napi_typeof(env, argv[3], &callbacktype3) == napi_ok); bool collect_resources; - napi_get_value_bool(env, argv[2], &collect_resources); + napi_get_value_bool(env, argv[3], &collect_resources); + + const ProfileFormat format_type = static_cast(format); + + if (format_type != ProfileFormat::kFormatThread && + format_type != ProfileFormat::kFormatChunk) { + napi_throw_error( + env, "NAPI_ERROR", + "StopProfiling expects a valid format type as second argument."); + + napi_value napi_null; + assert(napi_get_null(env, &napi_null) == napi_ok); + return napi_null; + } - napi_value js_profile = - TranslateProfile(env, cpu_profile, thread_id, collect_resources); + napi_value js_profile = TranslateProfile( + env, cpu_profile, format_type, profile->second->profile_start_timestamp(), + thread_id, collect_resources); napi_value measurements; napi_create_object(env, &measurements); if (profile->second->heap_usage_write_index() > 0) { static const char *memory_unit = "byte"; - napi_value heap_usage_measurements = TranslateMeasurements( - env, memory_unit, profile->second->heap_usage_write_index(), - profile->second->heap_usage_values(), - profile->second->heap_usage_timestamps()); + napi_value heap_usage_measurements = + TranslateMeasurements(env, format_type, memory_unit, + profile->second->profile_start_timestamp(), + profile->second->heap_usage_write_index(), + profile->second->heap_usage_values(), + profile->second->heap_usage_timestamps()); if (heap_usage_measurements != nullptr) { napi_set_named_property(env, measurements, "memory_footprint", @@ -1021,7 +1115,8 @@ static napi_value StopProfiling(napi_env env, napi_callback_info info) { if (profile->second->cpu_usage_write_index() > 0) { static const char *cpu_unit = "percent"; napi_value cpu_usage_measurements = TranslateMeasurementsDouble( - env, cpu_unit, profile->second->cpu_usage_write_index(), + env, format_type, cpu_unit, profile->second->profile_start_timestamp(), + profile->second->cpu_usage_write_index(), profile->second->cpu_usage_values(), profile->second->cpu_usage_timestamps()); diff --git a/packages/profiling-node/src/cpu_profiler.ts b/packages/profiling-node/src/cpu_profiler.ts index 433ade1d4b46..9ab470e2ca70 100644 --- a/packages/profiling-node/src/cpu_profiler.ts +++ b/packages/profiling-node/src/cpu_profiler.ts @@ -7,7 +7,13 @@ import { getAbi } from 'node-abi'; import { GLOBAL_OBJ, logger } from '@sentry/utils'; import { DEBUG_BUILD } from './debug-build'; -import type { PrivateV8CpuProfilerBindings, V8CpuProfilerBindings } from './types'; +import type { + PrivateV8CpuProfilerBindings, + RawChunkCpuProfile, + RawThreadCpuProfile, + V8CpuProfilerBindings, +} from './types'; +import type { ProfileFormat } from './types'; const stdlib = familySync(); const platform = process.env['BUILD_PLATFORM'] || _platform(); @@ -151,24 +157,39 @@ export function importCppBindingsModule(): PrivateV8CpuProfilerBindings { } const PrivateCpuProfilerBindings: PrivateV8CpuProfilerBindings = importCppBindingsModule(); -const CpuProfilerBindings: V8CpuProfilerBindings = { - startProfiling(name: string) { + +class Bindings implements V8CpuProfilerBindings { + public startProfiling(name: string): void { if (!PrivateCpuProfilerBindings) { DEBUG_BUILD && logger.log('[Profiling] Bindings not loaded, ignoring call to startProfiling.'); return; } return PrivateCpuProfilerBindings.startProfiling(name); - }, - stopProfiling(name: string) { + } + + public stopProfiling(name: string, format: ProfileFormat.THREAD): RawThreadCpuProfile | null; + public stopProfiling(name: string, format: ProfileFormat.CHUNK): RawChunkCpuProfile | null; + public stopProfiling( + name: string, + format: ProfileFormat.CHUNK | ProfileFormat.THREAD, + ): RawThreadCpuProfile | RawChunkCpuProfile | null { if (!PrivateCpuProfilerBindings) { DEBUG_BUILD && logger.log('[Profiling] Bindings not loaded or profile was never started, ignoring call to stopProfiling.'); return null; } - return PrivateCpuProfilerBindings.stopProfiling(name, threadId, !!GLOBAL_OBJ._sentryDebugIds); - }, -}; + + return PrivateCpuProfilerBindings.stopProfiling( + name, + format as unknown as any, + threadId, + !!GLOBAL_OBJ._sentryDebugIds, + ); + } +} + +const CpuProfilerBindings = new Bindings(); export { PrivateCpuProfilerBindings }; export { CpuProfilerBindings }; diff --git a/packages/profiling-node/src/integration.ts b/packages/profiling-node/src/integration.ts index f8a9ae4e5e4d..6cba86fe4f8d 100644 --- a/packages/profiling-node/src/integration.ts +++ b/packages/profiling-node/src/integration.ts @@ -1,31 +1,324 @@ -import { defineIntegration, getCurrentScope, getRootSpan, spanToJSON } from '@sentry/core'; +import { defineIntegration, getCurrentScope, getIsolationScope, getRootSpan, spanToJSON } from '@sentry/core'; import type { NodeClient } from '@sentry/node'; -import type { IntegrationFn, Span } from '@sentry/types'; +import type { Integration, IntegrationFn, Profile, ProfileChunk, Span } from '@sentry/types'; -import { logger } from '@sentry/utils'; +import { LRUMap, logger, timestampInSeconds, uuid4 } from '@sentry/utils'; +import { CpuProfilerBindings } from './cpu_profiler'; import { DEBUG_BUILD } from './debug-build'; import { NODE_MAJOR, NODE_VERSION } from './nodeVersion'; import { MAX_PROFILE_DURATION_MS, maybeProfileSpan, stopSpanProfile } from './spanProfileUtils'; -import type { Profile, RawThreadCpuProfile } from './types'; +import type { RawThreadCpuProfile } from './types'; +import { ProfileFormat } from './types'; + +import { + addProfilesToEnvelope, + createProfilingChunkEvent, + createProfilingEvent, + findProfiledTransactionsFromEnvelope, + makeProfileChunkEnvelope, +} from './utils'; + +const CHUNK_INTERVAL_MS = 5000; +const PROFILE_MAP = new LRUMap(50); +const PROFILE_TIMEOUTS: Record = {}; -import { addProfilesToEnvelope, createProfilingEvent, findProfiledTransactionsFromEnvelope } from './utils'; +function addToProfileQueue(profile_id: string, profile: RawThreadCpuProfile): void { + PROFILE_MAP.set(profile_id, profile); +} -const MAX_PROFILE_QUEUE_LENGTH = 50; -const PROFILE_QUEUE: RawThreadCpuProfile[] = []; -const PROFILE_TIMEOUTS: Record = {}; +function takeFromProfileQueue(profile_id: string): RawThreadCpuProfile | undefined { + const profile = PROFILE_MAP.get(profile_id); + PROFILE_MAP.remove(profile_id); + return profile; +} + +/** + * Instruments the client to automatically invoke the profiler on span start and stop events. + * @param client + */ +function setupAutomatedSpanProfiling(client: NodeClient): void { + const spanToProfileIdMap = new WeakMap(); + + client.on('spanStart', span => { + if (span !== getRootSpan(span)) { + return; + } + + const profile_id = maybeProfileSpan(client, span); + + 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; + + if (PROFILE_TIMEOUTS[profile_id]) { + global.clearTimeout(PROFILE_TIMEOUTS[profile_id]); + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete PROFILE_TIMEOUTS[profile_id]; + } + + // Enqueue a timeout to prevent profiles from running over max duration. + const timeout = global.setTimeout(() => { + DEBUG_BUILD && + logger.log('[Profiling] max profile duration elapsed, stopping profiling for:', spanToJSON(span).description); + + const profile = stopSpanProfile(span, profile_id); + if (profile) { + addToProfileQueue(profile_id, profile); + } + }, maxProfileDurationMs); + + // Unref timeout so it doesn't keep the process alive. + timeout.unref(); + + getCurrentScope().setContext('profile', { profile_id }); + spanToProfileIdMap.set(span, profile_id); + } + }); + + client.on('spanEnd', span => { + const profile_id = spanToProfileIdMap.get(span); + + if (profile_id) { + if (PROFILE_TIMEOUTS[profile_id]) { + global.clearTimeout(PROFILE_TIMEOUTS[profile_id]); + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete PROFILE_TIMEOUTS[profile_id]; + } + const profile = stopSpanProfile(span, profile_id); + + if (profile) { + addToProfileQueue(profile_id, profile); + } + } + }); + + client.on('beforeEnvelope', (envelope): void => { + // if not profiles are in queue, there is nothing to add to the envelope. + if (!PROFILE_MAP.size) { + return; + } + + const profiledTransactionEvents = findProfiledTransactionsFromEnvelope(envelope); + if (!profiledTransactionEvents.length) { + return; + } + + const profilesToAddToEnvelope: Profile[] = []; + + for (const profiledTransaction of profiledTransactionEvents) { + const profileContext = profiledTransaction.contexts?.['profile']; + const profile_id = profileContext?.['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 (profileContext) { + delete profiledTransaction.contexts?.['profile']; + } + + const cpuProfile = takeFromProfileQueue(profile_id); + if (!cpuProfile) { + DEBUG_BUILD && logger.log(`[Profiling] Could not retrieve profile for transaction: ${profile_id}`); + continue; + } + + const profile = createProfilingEvent(client, cpuProfile, profiledTransaction); + if (!profile) return; + + profilesToAddToEnvelope.push(profile); + + // @ts-expect-error profile does not inherit from Event + client.emit('preprocessEvent', profile, { + event_id: profiledTransaction.event_id, + }); + } + + addProfilesToEnvelope(envelope, profilesToAddToEnvelope); + }); +} + +interface ChunkData { + id: string; + timer: NodeJS.Timeout | undefined; + startTimestampMS: number; + startTraceID: string; +} +class ContinuousProfiler { + private _profilerId = uuid4(); + private _client: NodeClient | undefined = undefined; + private _chunkData: ChunkData | undefined = undefined; + + /** + * Called when the profiler is attached to the client (continuous mode is enabled). If of the profiler + * methods called before the profiler is initialized will result in a noop action with debug logs. + * @param client + */ + public initialize(client: NodeClient): void { + this._client = client; + } + + /** + * Recursively schedules chunk profiling to start and stop at a set interval. + * Once the user calls stop(), the current chunk will be stopped and flushed to Sentry and no new chunks will + * will be started. To restart continuous mode after calling stop(), the user must call start() again. + * @returns void + */ + public start(): void { + if (!this._client) { + // The client is not attached to the profiler if the user has not enabled continuous profiling. + // In this case, calling start() and stop() is a noop action.The reason this exists is because + // it makes the types easier to work with and avoids users having to do null checks. + DEBUG_BUILD && logger.log('[Profiling] Profiler was never attached to the client.'); + return; + } + if (this._chunkData) { + DEBUG_BUILD && + logger.log( + `[Profiling] Chunk with chunk_id ${this._chunkData.id} is still running, current chunk will be stopped a new chunk will be started.`, + ); + this.stop(); + } + + const traceId = + getCurrentScope().getPropagationContext().traceId || getIsolationScope().getPropagationContext().traceId; + this._initializeChunk(traceId); + this._startChunkProfiling(this._chunkData!); + } + + /** + * Stops the current chunk and flushes the profile to Sentry. + * @returns void + */ + public stop(): void { + if (this._chunkData?.timer) { + global.clearTimeout(this._chunkData.timer); + this._chunkData.timer = undefined; + DEBUG_BUILD && logger.log(`[Profiling] Stopping profiling chunk: ${this._chunkData.id}`); + } + if (!this._client) { + DEBUG_BUILD && + logger.log('[Profiling] Failed to collect profile, sentry client was never attached to the profiler.'); + return; + } + if (!this._chunkData?.id) { + DEBUG_BUILD && + logger.log(`[Profiling] Failed to collect profile for: ${this._chunkData?.id}, the chunk_id is missing.`); + return; + } + const profile = CpuProfilerBindings.stopProfiling(this._chunkData.id, ProfileFormat.CHUNK); + if (!profile || !this._chunkData.startTimestampMS) { + DEBUG_BUILD && logger.log(`[Profiling] _chunkiledStartTraceID to collect profile for: ${this._chunkData.id}`); + return; + } + if (profile) { + DEBUG_BUILD && logger.log(`[Profiling] Sending profile chunk ${this._chunkData.id}.`); + } + + DEBUG_BUILD && logger.log(`[Profiling] Profile chunk ${this._chunkData.id} sent to Sentry.`); + const chunk = createProfilingChunkEvent( + this._chunkData.startTimestampMS, + this._client, + this._client.getOptions(), + profile, + { + chunk_id: this._chunkData.id, + trace_id: this._chunkData.startTraceID, + profiler_id: this._profilerId, + }, + ); + + if (!chunk) { + DEBUG_BUILD && logger.log(`[Profiling] Failed to create profile chunk for: ${this._chunkData.id}`); + this._reset(); + return; + } + + this._flush(chunk); + // Depending on the profile and stack sizes, stopping the profile and converting + // the format may negatively impact the performance of the application. To avoid + // blocking for too long, enqueue the next chunk start inside the next macrotask. + // clear current chunk + this._reset(); + } + + /** + * Flushes the profile chunk to Sentry. + * @param chunk + */ + private _flush(chunk: ProfileChunk): void { + if (!this._client) { + DEBUG_BUILD && + logger.log('[Profiling] Failed to collect profile, sentry client was never attached to the profiler.'); + return; + } + + const transport = this._client.getTransport(); + if (!transport) { + DEBUG_BUILD && logger.log('[Profiling] No transport available to send profile chunk.'); + return; + } + + const dsn = this._client.getDsn(); + const metadata = this._client.getSdkMetadata(); + const tunnel = this._client.getOptions().tunnel; + + const envelope = makeProfileChunkEnvelope(chunk, metadata?.sdk, tunnel, dsn); + transport.send(envelope).then(null, reason => { + DEBUG_BUILD && logger.error('Error while sending profile chunk envelope:', reason); + }); + } + + /** + * Starts the profiler and registers the flush timer for a given chunk. + * @param chunk + */ + private _startChunkProfiling(chunk: ChunkData): void { + CpuProfilerBindings.startProfiling(chunk.id!); + DEBUG_BUILD && logger.log(`[Profiling] starting profiling chunk: ${chunk.id}`); + + chunk.timer = global.setTimeout(() => { + DEBUG_BUILD && logger.log(`[Profiling] Stopping profiling chunk: ${chunk.id}`); + this.stop(); + DEBUG_BUILD && logger.log('[Profiling] Starting new profiling chunk.'); + setImmediate(this.start.bind(this)); + }, CHUNK_INTERVAL_MS); + + // Unref timeout so it doesn't keep the process alive. + chunk.timer.unref(); + } -function addToProfileQueue(profile: RawThreadCpuProfile): void { - PROFILE_QUEUE.push(profile); + /** + * Initializes new profile chunk metadata + */ + private _initializeChunk(traceId: string): void { + this._chunkData = { + id: uuid4(), + startTraceID: traceId, + startTimestampMS: timestampInSeconds(), + timer: undefined, + }; + } - // We only want to keep the last n profiles in the queue. - if (PROFILE_QUEUE.length > MAX_PROFILE_QUEUE_LENGTH) { - PROFILE_QUEUE.shift(); + /** + * Resets the current chunk state. + */ + private _reset(): void { + this._chunkData = undefined; } } +export interface ProfilingIntegration extends Integration { + _profiler: ContinuousProfiler; +} + /** Exported only for tests. */ -export const _nodeProfilingIntegration = (() => { +export const _nodeProfilingIntegration = ((): ProfilingIntegration => { if (DEBUG_BUILD && ![16, 18, 20, 22].includes(NODE_MAJOR)) { logger.warn( `[Profiling] You are using a Node.js version that does not have prebuilt binaries (${NODE_VERSION}).`, @@ -37,129 +330,32 @@ export const _nodeProfilingIntegration = (() => { return { name: 'ProfilingIntegration', + _profiler: new ContinuousProfiler(), setup(client: NodeClient) { - const spanToProfileIdMap = new WeakMap(); - - client.on('spanStart', span => { - if (span !== getRootSpan(span)) { - return; + DEBUG_BUILD && logger.log('[Profiling] Profiling integration setup.'); + const options = client.getOptions(); + + const mode = + (options.profilesSampleRate === undefined || options.profilesSampleRate === 0) && !options.profilesSampler + ? 'continuous' + : 'span'; + switch (mode) { + case 'continuous': { + DEBUG_BUILD && logger.log('[Profiling] Continuous profiler mode enabled.'); + this._profiler.initialize(client); + break; } - - const profile_id = maybeProfileSpan(client, span, 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; - - if (PROFILE_TIMEOUTS[profile_id]) { - global.clearTimeout(PROFILE_TIMEOUTS[profile_id]); - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete PROFILE_TIMEOUTS[profile_id]; - } - - // Enqueue a timeout to prevent profiles from running over max duration. - PROFILE_TIMEOUTS[profile_id] = global.setTimeout(() => { - DEBUG_BUILD && - logger.log( - '[Profiling] max profile duration elapsed, stopping profiling for:', - spanToJSON(span).description, - ); - - const profile = stopSpanProfile(span, profile_id); - if (profile) { - addToProfileQueue(profile); - } - }, maxProfileDurationMs); - - getCurrentScope().setContext('profile', { profile_id }); - - spanToProfileIdMap.set(span, profile_id); + // Default to span profiling when no mode profiler mode is set + case 'span': + case undefined: { + DEBUG_BUILD && logger.log('[Profiling] Span profiler mode enabled.'); + setupAutomatedSpanProfiling(client); + break; } - }); - - client.on('spanEnd', span => { - const profile_id = spanToProfileIdMap.get(span); - - if (profile_id) { - if (PROFILE_TIMEOUTS[profile_id]) { - global.clearTimeout(PROFILE_TIMEOUTS[profile_id]); - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete PROFILE_TIMEOUTS[profile_id]; - } - const profile = stopSpanProfile(span, 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 profileContext = profiledTransaction.contexts?.['profile']; - const profile_id = profileContext?.['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 (profileContext) { - 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(client, cpuProfile, profiledTransaction); - - if (client.emit && profile) { - const integrations = - client['_integrations'] && client['_integrations'] !== null && !Array.isArray(client['_integrations']) - ? Object.keys(client['_integrations']) - : undefined; - - // @ts-expect-error bad overload due to unknown event - client.emit('preprocessEvent', profile, { - event_id: profiledTransaction.event_id, - integrations, - }); - } - - if (profile) { - profilesToAddToEnvelope.push(profile); - } + default: { + DEBUG_BUILD && logger.warn(`[Profiling] Unknown profiler mode: ${mode}, profiler was not initialized`); } - - addProfilesToEnvelope(envelope, profilesToAddToEnvelope); - }); + } }, }; }) satisfies IntegrationFn; diff --git a/packages/profiling-node/src/spanProfileUtils.ts b/packages/profiling-node/src/spanProfileUtils.ts index 957ee0e16303..1b347a61b741 100644 --- a/packages/profiling-node/src/spanProfileUtils.ts +++ b/packages/profiling-node/src/spanProfileUtils.ts @@ -5,6 +5,7 @@ import { logger, uuid4 } from '@sentry/utils'; import { CpuProfilerBindings } from './cpu_profiler'; import { DEBUG_BUILD } from './debug-build'; +import type { RawThreadCpuProfile } from './types'; import { isValidSampleRate } from './utils'; export const MAX_PROFILE_DURATION_MS = 30 * 1000; @@ -107,16 +108,13 @@ export function maybeProfileSpan( * @param profile_id * @returns */ -export function stopSpanProfile( - span: Span, - profile_id: string | undefined, -): ReturnType<(typeof CpuProfilerBindings)['stopProfiling']> | null { +export function stopSpanProfile(span: Span, profile_id: string | undefined): RawThreadCpuProfile | null { // Should not happen, but satisfy the type checker and be safe regardless. if (!profile_id) { return null; } - const profile = CpuProfilerBindings.stopProfiling(profile_id); + const profile = CpuProfilerBindings.stopProfiling(profile_id, 0); DEBUG_BUILD && logger.log(`[Profiling] stopped profiling of transaction: ${spanToJSON(span).description}`); diff --git a/packages/profiling-node/src/types.ts b/packages/profiling-node/src/types.ts index 3042335269eb..1c2c444887cd 100644 --- a/packages/profiling-node/src/types.ts +++ b/packages/profiling-node/src/types.ts @@ -6,7 +6,11 @@ interface Sample { elapsed_since_start_ns: string; } -type Stack = number[]; +interface ChunkSample { + stack_id: number; + thread_id: string; + timestamp: number; +} type Frame = { function: string; @@ -23,15 +27,6 @@ interface Measurement { }[]; } -export interface DebugImage { - code_file: string; - type: string; - debug_id: string; - image_addr?: string; - image_size?: number; - image_vmaddr?: string; -} - // Profile is marked as optional because it is deleted from the metadata // by the integration before the event is processed by other integrations. export interface ProfiledEvent extends Event { @@ -40,66 +35,50 @@ export interface ProfiledEvent extends Event { }; } -export interface RawThreadCpuProfile { +interface BaseProfile { profile_id?: string; - stacks: ReadonlyArray; - samples: ReadonlyArray; - frames: ReadonlyArray; - resources: ReadonlyArray; + stacks: number[][]; + frames: Frame[]; + resources: string[]; profiler_logging_mode: 'eager' | 'lazy'; measurements: Record; } -export interface ThreadCpuProfile { - stacks: ReadonlyArray; - samples: ReadonlyArray; - frames: ReadonlyArray; - thread_metadata: Record; - queue_metadata?: Record; +export interface RawThreadCpuProfile extends BaseProfile { + samples: Sample[]; +} + +export interface RawChunkCpuProfile extends BaseProfile { + samples: ChunkSample[]; } export interface PrivateV8CpuProfilerBindings { startProfiling(name: string): void; - stopProfiling(name: string, threadId: number, collectResources: boolean): RawThreadCpuProfile | null; + + stopProfiling( + name: string, + format: ProfileFormat.THREAD, + threadId: number, + collectResources: boolean, + ): RawThreadCpuProfile | null; + stopProfiling( + name: string, + format: ProfileFormat.CHUNK, + threadId: number, + collectResources: boolean, + ): RawChunkCpuProfile | null; + + // Helper methods exposed for testing getFrameModule(abs_path: string): string; } +export enum ProfileFormat { + THREAD = 0, + CHUNK = 1, +} + export interface V8CpuProfilerBindings { startProfiling(name: string): void; - stopProfiling(name: string): RawThreadCpuProfile | null; -} -export interface Profile { - event_id: string; - version: string; - os: { - name: string; - version: string; - build_number: string; - }; - runtime: { - name: string; - version: string; - }; - device: { - architecture: string; - is_emulator: boolean; - locale: string; - manufacturer: string; - model: string; - }; - timestamp: string; - release: string; - environment: string; - platform: string; - profile: ThreadCpuProfile; - debug_meta?: { - images: DebugImage[]; - }; - transaction: { - name: string; - id: string; - trace_id: string; - active_thread_id: string; - }; - measurements: Record; + stopProfiling(name: string, format: ProfileFormat.THREAD): RawThreadCpuProfile | null; + stopProfiling(name: string, format: ProfileFormat.CHUNK): RawChunkCpuProfile | null; } diff --git a/packages/profiling-node/src/utils.ts b/packages/profiling-node/src/utils.ts index 884e71c3d10e..5661129791bb 100644 --- a/packages/profiling-node/src/utils.ts +++ b/packages/profiling-node/src/utils.ts @@ -1,19 +1,37 @@ -import * as os from 'node:os'; -import { env, versions } from 'node:process'; -import { isMainThread, threadId } from 'node:worker_threads'; -import type { Client, Context, Envelope, Event, StackFrame, StackParser } from '@sentry/types'; - -import { GLOBAL_OBJ, forEachEnvelopeItem, logger } from '@sentry/utils'; - +/* eslint-disable max-lines */ +import * as os from 'os'; +import type { + Client, + Context, + DebugImage, + DsnComponents, + Envelope, + Event, + EventEnvelopeHeaders, + Profile, + ProfileChunk, + ProfileChunkEnvelope, + SdkInfo, + StackFrame, + StackParser, + ThreadCpuProfile, +} from '@sentry/types'; +import { GLOBAL_OBJ, createEnvelope, dsnToString, forEachEnvelopeItem, logger, uuid4 } from '@sentry/utils'; + +import { env, versions } from 'process'; +import { isMainThread, threadId } from 'worker_threads'; + +import type { ProfileChunkItem } from '@sentry/types/build/types/envelope'; +import type { ContinuousThreadCpuProfile } from '../../types/src/profiling'; import { DEBUG_BUILD } from './debug-build'; -import type { Profile, RawThreadCpuProfile, ThreadCpuProfile } from './types'; -import type { DebugImage } from './types'; +import type { RawChunkCpuProfile, RawThreadCpuProfile } from './types'; // We require the file because if we import it, it will be included in the bundle. // I guess tsc does not check file contents when it's imported. const THREAD_ID_STRING = String(threadId); const THREAD_NAME = isMainThread ? 'main' : 'worker'; const FORMAT_VERSION = '1'; +const CONTINUOUS_FORMAT_VERSION = '2'; // Os machine was backported to 16.18, but this was not reflected in the types // @ts-expect-error ignore missing @@ -32,7 +50,9 @@ const ARCH = os.arch(); * @param {ThreadCpuProfile | RawThreadCpuProfile} profile * @returns {boolean} */ -function isRawThreadCpuProfile(profile: ThreadCpuProfile | RawThreadCpuProfile): profile is RawThreadCpuProfile { +function isRawThreadCpuProfile( + profile: ThreadCpuProfile | RawThreadCpuProfile | ContinuousThreadCpuProfile | RawChunkCpuProfile, +): profile is RawThreadCpuProfile | RawChunkCpuProfile { return !('thread_metadata' in profile); } @@ -43,7 +63,9 @@ function isRawThreadCpuProfile(profile: ThreadCpuProfile | RawThreadCpuProfile): * @param {ThreadCpuProfile | RawThreadCpuProfile} profile * @returns {ThreadCpuProfile} */ -export function enrichWithThreadInformation(profile: ThreadCpuProfile | RawThreadCpuProfile): ThreadCpuProfile { +export function enrichWithThreadInformation( + profile: ThreadCpuProfile | RawThreadCpuProfile | ContinuousThreadCpuProfile | RawChunkCpuProfile, +): ThreadCpuProfile | ContinuousThreadCpuProfile { if (!isRawThreadCpuProfile(profile)) { return profile; } @@ -57,7 +79,7 @@ export function enrichWithThreadInformation(profile: ThreadCpuProfile | RawThrea name: THREAD_NAME, }, }, - }; + } as ThreadCpuProfile | ContinuousThreadCpuProfile; } /** @@ -88,7 +110,6 @@ export function createProfilingEvent(client: Client, profile: RawThreadCpuProfil * @param {options} * @returns {Profile} */ - function createProfilePayload( client: Client, cpuProfile: RawThreadCpuProfile, @@ -146,7 +167,7 @@ function createProfilePayload( debug_meta: { images: applyDebugMetadata(client, cpuProfile.resources), }, - profile: enrichedThreadProfile, + profile: enrichedThreadProfile as ThreadCpuProfile, transaction: { name: transaction, id: event_id, @@ -158,6 +179,82 @@ function createProfilePayload( return profile; } +/** + * Create a profile chunk from raw thread profile + * @param {RawThreadCpuProfile} cpuProfile + * @param {options} + * @returns {Profile} + */ +function createProfileChunkPayload( + client: Client, + cpuProfile: RawChunkCpuProfile, + { + release, + environment, + start_timestamp, + trace_id, + profiler_id, + chunk_id, + }: { + release: string; + environment: string; + start_timestamp: number; + trace_id: string | undefined; + chunk_id: string; + profiler_id: string; + }, +): ProfileChunk { + // Log a warning if the profile has an invalid traceId (should be uuidv4). + // All profiles and transactions are rejected if this is the case and we want to + // warn users that this is happening if they enable debug flag + if (trace_id && trace_id.length !== 32) { + DEBUG_BUILD && logger.log(`[Profiling] Invalid traceId: ${trace_id} on profiled event`); + } + + const enrichedThreadProfile = enrichWithThreadInformation(cpuProfile); + + const profile: ProfileChunk = { + chunk_id: chunk_id, + profiler_id: profiler_id, + timestamp: new Date(start_timestamp).toISOString(), + platform: 'node', + version: CONTINUOUS_FORMAT_VERSION, + release: release, + environment: environment, + measurements: cpuProfile.measurements, + debug_meta: { + images: applyDebugMetadata(client, cpuProfile.resources), + }, + profile: enrichedThreadProfile as ContinuousThreadCpuProfile, + }; + + return profile; +} + +/** + * Creates a profiling chunk envelope item, if the profile does not pass validation, returns null. + */ +export function createProfilingChunkEvent( + start_timestamp: number, + client: Client, + options: { release?: string; environment?: string }, + profile: RawChunkCpuProfile, + identifiers: { trace_id: string | undefined; chunk_id: string; profiler_id: string }, +): ProfileChunk | null { + if (!isValidProfileChunk(profile)) { + return null; + } + + return createProfileChunkPayload(client, profile, { + release: options.release ?? '', + environment: options.environment ?? '', + start_timestamp: start_timestamp, + trace_id: identifiers.trace_id ?? '', + chunk_id: identifiers.chunk_id, + profiler_id: identifiers.profiler_id, + }); +} + /** * Checks the given sample rate to make sure it is valid type and value (a boolean, or a number between 0 and 1). * @param {unknown} rate @@ -210,6 +307,24 @@ export function isValidProfile(profile: RawThreadCpuProfile): profile is RawThre return true; } +/** + * Checks if the profile chunk is valid and can be sent to Sentry. + * @param profile + * @returns + */ +export function isValidProfileChunk(profile: RawChunkCpuProfile): profile is RawChunkCpuProfile { + if (profile.samples.length <= 1) { + DEBUG_BUILD && + // Log a warning if the profile has less than 2 samples so users can know why + // they are not seeing any profiling data and we cant avoid the back and forth + // of asking them to provide us with a dump of the profile data. + logger.log('[Profiling] Discarding profile chunk because it contains less than 2 samples'); + return false; + } + + return true; +} + /** * Adds items to envelope if they are not already present - mutates the envelope. * @param {Envelope} envelope @@ -262,6 +377,41 @@ export function findProfiledTransactionsFromEnvelope(envelope: Envelope): Event[ return events; } +/** + * Creates event envelope headers for a profile chunk. This is separate from createEventEnvelopeHeaders util + * as the profile chunk does not conform to the sentry event type + */ +export function createEventEnvelopeHeaders( + sdkInfo: SdkInfo | undefined, + tunnel: string | undefined, + dsn?: DsnComponents, +): EventEnvelopeHeaders { + return { + event_id: uuid4(), + sent_at: new Date().toISOString(), + ...(sdkInfo && { sdk: sdkInfo }), + ...(!!tunnel && dsn && { dsn: dsnToString(dsn) }), + }; +} + +/** + * Creates a standalone profile_chunk envelope. + */ +export function makeProfileChunkEnvelope( + chunk: ProfileChunk, + sdkInfo: SdkInfo | undefined, + tunnel: string | undefined, + dsn?: DsnComponents, +): ProfileChunkEnvelope { + const profileChunkHeader: ProfileChunkItem[0] = { + type: 'profile_chunk', + }; + + return createEnvelope(createEventEnvelopeHeaders(sdkInfo, tunnel, dsn), [ + [profileChunkHeader, chunk], + ]); +} + const debugIdStackParserCache = new WeakMap>(); /** diff --git a/packages/profiling-node/test/cpu_profiler.test.ts b/packages/profiling-node/test/cpu_profiler.test.ts index 8f66a91cb5ef..be12e740510a 100644 --- a/packages/profiling-node/test/cpu_profiler.test.ts +++ b/packages/profiling-node/test/cpu_profiler.test.ts @@ -1,5 +1,6 @@ +import type { ContinuousThreadCpuProfile, ThreadCpuProfile } from '@sentry/types'; import { CpuProfilerBindings, PrivateCpuProfilerBindings } from '../src/cpu_profiler'; -import type { RawThreadCpuProfile, ThreadCpuProfile } from '../src/types'; +import type { RawThreadCpuProfile } from '../src/types'; // Required because we test a hypothetical long profile // and we cannot use advance timers as the c++ relies on @@ -18,13 +19,16 @@ const fibonacci = (n: number): number => { }; const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); -const profiled = async (name: string, fn: () => void) => { +const profiled = async (name: string, fn: () => void, format: 0 | 1 = 0) => { CpuProfilerBindings.startProfiling(name); await fn(); - return CpuProfilerBindings.stopProfiling(name); + return CpuProfilerBindings.stopProfiling(name, format); }; -const assertValidSamplesAndStacks = (stacks: ThreadCpuProfile['stacks'], samples: ThreadCpuProfile['samples']) => { +const assertValidSamplesAndStacks = ( + stacks: ThreadCpuProfile['stacks'], + samples: ThreadCpuProfile['samples'] | ContinuousThreadCpuProfile['samples'], +) => { expect(stacks.length).toBeGreaterThan(0); expect(samples.length).toBeGreaterThan(0); expect(stacks.length <= samples.length).toBe(true); @@ -68,16 +72,25 @@ describe('Private bindings', () => { PrivateCpuProfilerBindings.startProfiling('profiled-program'); await wait(100); expect(() => { - const profile = PrivateCpuProfilerBindings.stopProfiling('profiled-program', 0, false); + const profile = PrivateCpuProfilerBindings.stopProfiling('profiled-program', 0, 0, false); if (!profile) throw new Error('No profile'); }).not.toThrow(); }); + it('throws if invalid format is supplied', async () => { + PrivateCpuProfilerBindings.startProfiling('profiled-program'); + await wait(100); + expect(() => { + const profile = PrivateCpuProfilerBindings.stopProfiling('profiled-program', Number.MAX_SAFE_INTEGER, 0, false); + if (!profile) throw new Error('No profile'); + }).toThrow('StopProfiling expects a valid format type as second argument.'); + }); + it('collects resources', async () => { PrivateCpuProfilerBindings.startProfiling('profiled-program'); await wait(100); - const profile = PrivateCpuProfilerBindings.stopProfiling('profiled-program', 0, true); + const profile = PrivateCpuProfilerBindings.stopProfiling('profiled-program', 0, 0, true); if (!profile) throw new Error('No profile'); expect(profile.resources.length).toBeGreaterThan(0); @@ -94,7 +107,7 @@ describe('Private bindings', () => { PrivateCpuProfilerBindings.startProfiling('profiled-program'); await wait(100); - const profile = PrivateCpuProfilerBindings.stopProfiling('profiled-program', 0, false); + const profile = PrivateCpuProfilerBindings.stopProfiling('profiled-program', 0, 0, false); if (!profile) throw new Error('No profile'); expect(profile.resources.length).toBe(0); @@ -159,27 +172,27 @@ describe('Profiler bindings', () => { CpuProfilerBindings.startProfiling('same-title'); CpuProfilerBindings.startProfiling('same-title'); - const first = CpuProfilerBindings.stopProfiling('same-title'); - const second = CpuProfilerBindings.stopProfiling('same-title'); + const first = CpuProfilerBindings.stopProfiling('same-title', 0); + const second = CpuProfilerBindings.stopProfiling('same-title', 0); expect(first).not.toBe(null); expect(second).toBe(null); }); - it('weird cases', () => { + it('multiple calls with same title', () => { CpuProfilerBindings.startProfiling('same-title'); expect(() => { - CpuProfilerBindings.stopProfiling('same-title'); - CpuProfilerBindings.stopProfiling('same-title'); + CpuProfilerBindings.stopProfiling('same-title', 0); + CpuProfilerBindings.stopProfiling('same-title', 0); }).not.toThrow(); }); it('does not crash if stopTransaction is called before startTransaction', () => { - expect(CpuProfilerBindings.stopProfiling('does not exist')).toBe(null); + expect(CpuProfilerBindings.stopProfiling('does not exist', 0)).toBe(null); }); it('does crash if name is invalid', () => { - expect(() => CpuProfilerBindings.stopProfiling('')).toThrow(); + expect(() => CpuProfilerBindings.stopProfiling('', 0)).toThrow(); // @ts-expect-error test invalid input expect(() => CpuProfilerBindings.stopProfiling(undefined)).toThrow(); // @ts-expect-error test invalid input @@ -189,8 +202,8 @@ describe('Profiler bindings', () => { }); it('does not throw if stopTransaction is called before startTransaction', () => { - expect(CpuProfilerBindings.stopProfiling('does not exist')).toBe(null); - expect(() => CpuProfilerBindings.stopProfiling('does not exist')).not.toThrow(); + expect(CpuProfilerBindings.stopProfiling('does not exist', 0)).toBe(null); + expect(() => CpuProfilerBindings.stopProfiling('does not exist', 0)).not.toThrow(); }); it('compiles with eager logging by default', async () => { @@ -202,6 +215,27 @@ describe('Profiler bindings', () => { expect(profile.profiler_logging_mode).toBe('eager'); }); + it('chunk format type', async () => { + const profile = await profiled( + 'non nullable stack', + async () => { + await wait(1000); + fibonacci(36); + await wait(1000); + }, + 1, + ); + + if (!profile) fail('Profile is null'); + + for (const sample of profile.samples) { + if (!('timestamp' in sample)) { + throw new Error(`Sample ${JSON.stringify(sample)} has no timestamp`); + } + expect(sample.timestamp).toBeDefined(); + } + }); + it('stacks are not null', async () => { const profile = await profiled('non nullable stack', async () => { await wait(1000); @@ -216,7 +250,7 @@ describe('Profiler bindings', () => { it('samples at ~99hz', async () => { CpuProfilerBindings.startProfiling('profile'); await wait(100); - const profile = CpuProfilerBindings.stopProfiling('profile'); + const profile = CpuProfilerBindings.stopProfiling('profile', 0); if (!profile) fail('Profile is null'); @@ -240,7 +274,7 @@ describe('Profiler bindings', () => { it('collects memory footprint', async () => { CpuProfilerBindings.startProfiling('profile'); await wait(1000); - const profile = CpuProfilerBindings.stopProfiling('profile'); + const profile = CpuProfilerBindings.stopProfiling('profile', 0); const heap_usage = profile?.measurements['memory_footprint']; if (!heap_usage) { @@ -256,7 +290,7 @@ describe('Profiler bindings', () => { it('collects cpu usage', async () => { CpuProfilerBindings.startProfiling('profile'); await wait(1000); - const profile = CpuProfilerBindings.stopProfiling('profile'); + const profile = CpuProfilerBindings.stopProfiling('profile', 0); const cpu_usage = profile?.measurements['cpu_usage']; if (!cpu_usage) { @@ -272,7 +306,7 @@ describe('Profiler bindings', () => { it('does not overflow measurement buffer if profile runs longer than 30s', async () => { CpuProfilerBindings.startProfiling('profile'); await wait(35000); - const profile = CpuProfilerBindings.stopProfiling('profile'); + const profile = CpuProfilerBindings.stopProfiling('profile', 0); expect(profile).not.toBe(null); expect(profile?.measurements?.['cpu_usage']?.values.length).toBeLessThanOrEqual(300); expect(profile?.measurements?.['memory_footprint']?.values.length).toBeLessThanOrEqual(300); diff --git a/packages/profiling-node/test/integration.test.ts b/packages/profiling-node/test/integration.test.ts index 040ed5297205..92d1018e18d4 100644 --- a/packages/profiling-node/test/integration.test.ts +++ b/packages/profiling-node/test/integration.test.ts @@ -35,7 +35,7 @@ describe('ProfilingIntegration', () => { getTransport: () => transport, } as unknown as NodeClient; - integration.setup(client); + integration?.setup?.(client); // eslint-disable-next-line @typescript-eslint/unbound-method expect(transport.send).not.toHaveBeenCalled(); @@ -54,6 +54,7 @@ describe('ProfilingIntegration', () => { getOptions: () => { return { _metadata: {}, + profilesSampleRate: 1, }; }, getDsn: () => { @@ -64,7 +65,7 @@ describe('ProfilingIntegration', () => { const spy = jest.spyOn(client, 'on'); - integration.setup(client); + integration?.setup?.(client); expect(spy).toHaveBeenCalledTimes(3); expect(spy).toHaveBeenCalledWith('spanStart', expect.any(Function)); diff --git a/packages/profiling-node/test/spanProfileUtils.test.ts b/packages/profiling-node/test/spanProfileUtils.test.ts index 687b6ca60768..9cc2ae58a972 100644 --- a/packages/profiling-node/test/spanProfileUtils.test.ts +++ b/packages/profiling-node/test/spanProfileUtils.test.ts @@ -1,10 +1,13 @@ import * as Sentry from '@sentry/node'; import { getMainCarrier } from '@sentry/core'; +import type { NodeClientOptions } from '@sentry/node/build/types/types'; import type { Transport } from '@sentry/types'; import { GLOBAL_OBJ, createEnvelope, logger } from '@sentry/utils'; import { CpuProfilerBindings } from '../src/cpu_profiler'; -import { _nodeProfilingIntegration } from '../src/integration'; +import { type ProfilingIntegration, _nodeProfilingIntegration } from '../src/integration'; + +jest.setTimeout(10000); function makeClientWithHooks(): [Sentry.NodeClient, Transport] { const integration = _nodeProfilingIntegration(); @@ -28,9 +31,49 @@ function makeClientWithHooks(): [Sentry.NodeClient, Transport] { return [client, client.getTransport() as Transport]; } +function makeContinuousProfilingClient(): [Sentry.NodeClient, Transport] { + const integration = _nodeProfilingIntegration(); + const client = new Sentry.NodeClient({ + stackParser: Sentry.defaultStackParser, + tracesSampleRate: 1, + profilesSampleRate: undefined, + debug: true, + environment: 'test-environment', + dsn: 'https://7fa19397baaf433f919fbe02228d5470@o1137848.ingest.sentry.io/6625302', + integrations: [integration], + transport: _opts => + Sentry.makeNodeTransport({ + url: 'https://7fa19397baaf433f919fbe02228d5470@o1137848.ingest.sentry.io/6625302', + recordDroppedEvent: () => { + return undefined; + }, + }), + }); + + return [client, client.getTransport() as Transport]; +} + +function makeClientOptions( + options: Omit, +): NodeClientOptions { + return { + stackParser: Sentry.defaultStackParser, + integrations: [_nodeProfilingIntegration()], + debug: true, + transport: _opts => + Sentry.makeNodeTransport({ + url: 'https://7fa19397baaf433f919fbe02228d5470@o1137848.ingest.sentry.io/6625302', + recordDroppedEvent: () => { + return undefined; + }, + }), + ...options, + }; +} + const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); -describe('spanProfileUtils', () => { +describe('automated span instrumentation', () => { beforeEach(() => { jest.useRealTimers(); // We will mock the carrier as if it has been initialized by the SDK, else everything is short circuited @@ -65,6 +108,7 @@ describe('spanProfileUtils', () => { Sentry.setCurrentClient(client); client.init(); + // @ts-expect-error we just mock the return type and ignore the signature jest.spyOn(CpuProfilerBindings, 'stopProfiling').mockImplementation(() => { return { samples: [ @@ -103,6 +147,7 @@ describe('spanProfileUtils', () => { Sentry.setCurrentClient(client); client.init(); + // @ts-expect-error we just mock the return type and ignore the signature jest.spyOn(CpuProfilerBindings, 'stopProfiling').mockImplementation(() => { return { samples: [ @@ -248,6 +293,21 @@ describe('spanProfileUtils', () => { }, }); }); + + it('automated span instrumentation does not support continuous profiling', () => { + const startProfilingSpy = jest.spyOn(CpuProfilerBindings, 'startProfiling'); + + const [client] = makeClientWithHooks(); + Sentry.setCurrentClient(client); + client.init(); + + const integration = client.getIntegrationByName('ProfilingIntegration'); + if (!integration) { + throw new Error('Profiling integration not found'); + } + integration._profiler.start(); + expect(startProfilingSpy).not.toHaveBeenCalled(); + }); }); it('does not crash if stop is called multiple times', async () => { @@ -270,6 +330,7 @@ describe('spanProfileUtils', () => { 'Error\n at filename3.js (filename3.js:36:15)': 'bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbb', }; + // @ts-expect-error we just mock the return type and ignore the signature jest.spyOn(CpuProfilerBindings, 'stopProfiling').mockImplementation(() => { return { samples: [ @@ -322,3 +383,290 @@ describe('spanProfileUtils', () => { }); }); }); + +describe('continuous profiling', () => { + beforeEach(() => { + jest.useFakeTimers(); + // We will mock the carrier as if it has been initialized by the SDK, else everything is short circuited + getMainCarrier().__SENTRY__ = {}; + GLOBAL_OBJ._sentryDebugIds = undefined as any; + }); + afterEach(() => { + const client = Sentry.getClient(); + const integration = client?.getIntegrationByName('ProfilingIntegration'); + + if (integration) { + integration._profiler.stop(); + } + + jest.clearAllMocks(); + jest.restoreAllMocks(); + jest.runAllTimers(); + delete getMainCarrier().__SENTRY__; + }); + + it('initializes the continuous profiler and binds the sentry client', () => { + const startProfilingSpy = jest.spyOn(CpuProfilerBindings, 'startProfiling'); + + const [client] = makeContinuousProfilingClient(); + Sentry.setCurrentClient(client); + client.init(); + + expect(startProfilingSpy).not.toHaveBeenCalledTimes(1); + + const integration = client.getIntegrationByName('ProfilingIntegration'); + if (!integration) { + throw new Error('Profiling integration not found'); + } + integration._profiler.start(); + + expect(integration._profiler).toBeDefined(); + expect(integration._profiler['_client']).toBe(client); + }); + + it('starts a continuous profile', () => { + const startProfilingSpy = jest.spyOn(CpuProfilerBindings, 'startProfiling'); + + const [client] = makeContinuousProfilingClient(); + Sentry.setCurrentClient(client); + client.init(); + + expect(startProfilingSpy).not.toHaveBeenCalledTimes(1); + const integration = client.getIntegrationByName('ProfilingIntegration'); + if (!integration) { + throw new Error('Profiling integration not found'); + } + integration._profiler.start(); + expect(startProfilingSpy).toHaveBeenCalledTimes(1); + }); + + it('multiple calls to start abort previous profile', () => { + const startProfilingSpy = jest.spyOn(CpuProfilerBindings, 'startProfiling'); + const stopProfilingSpy = jest.spyOn(CpuProfilerBindings, 'stopProfiling'); + + const [client] = makeContinuousProfilingClient(); + Sentry.setCurrentClient(client); + client.init(); + + expect(startProfilingSpy).not.toHaveBeenCalledTimes(1); + const integration = client.getIntegrationByName('ProfilingIntegration'); + if (!integration) { + throw new Error('Profiling integration not found'); + } + integration._profiler.start(); + integration._profiler.start(); + expect(startProfilingSpy).toHaveBeenCalledTimes(2); + expect(stopProfilingSpy).toHaveBeenCalledTimes(1); + }); + + it('restarts a new chunk after previous', async () => { + const startProfilingSpy = jest.spyOn(CpuProfilerBindings, 'startProfiling'); + const stopProfilingSpy = jest.spyOn(CpuProfilerBindings, 'stopProfiling'); + + const [client] = makeContinuousProfilingClient(); + Sentry.setCurrentClient(client); + client.init(); + + expect(startProfilingSpy).not.toHaveBeenCalledTimes(1); + const integration = client.getIntegrationByName('ProfilingIntegration'); + if (!integration) { + throw new Error('Profiling integration not found'); + } + integration._profiler.start(); + + jest.advanceTimersByTime(5001); + expect(stopProfilingSpy).toHaveBeenCalledTimes(1); + expect(startProfilingSpy).toHaveBeenCalledTimes(2); + }); + + it('stops a continuous profile after interval', async () => { + const startProfilingSpy = jest.spyOn(CpuProfilerBindings, 'startProfiling'); + const stopProfilingSpy = jest.spyOn(CpuProfilerBindings, 'stopProfiling'); + + const [client] = makeContinuousProfilingClient(); + Sentry.setCurrentClient(client); + client.init(); + + expect(startProfilingSpy).not.toHaveBeenCalledTimes(1); + const integration = client.getIntegrationByName('ProfilingIntegration'); + if (!integration) { + throw new Error('Profiling integration not found'); + } + integration._profiler.start(); + + jest.advanceTimersByTime(5001); + expect(stopProfilingSpy).toHaveBeenCalledTimes(1); + }); + + it('manullly stopping a chunk doesnt restart the profiler', async () => { + const startProfilingSpy = jest.spyOn(CpuProfilerBindings, 'startProfiling'); + const stopProfilingSpy = jest.spyOn(CpuProfilerBindings, 'stopProfiling'); + + const [client] = makeContinuousProfilingClient(); + Sentry.setCurrentClient(client); + client.init(); + + expect(startProfilingSpy).not.toHaveBeenCalledTimes(1); + const integration = client.getIntegrationByName('ProfilingIntegration'); + if (!integration) { + throw new Error('Profiling integration not found'); + } + integration._profiler.start(); + + jest.advanceTimersByTime(1000); + + integration._profiler.stop(); + expect(stopProfilingSpy).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(1000); + expect(startProfilingSpy).toHaveBeenCalledTimes(1); + }); + + it('continuous mode does not instrument spans', () => { + const startProfilingSpy = jest.spyOn(CpuProfilerBindings, 'startProfiling'); + + const [client] = makeContinuousProfilingClient(); + Sentry.setCurrentClient(client); + client.init(); + + Sentry.startInactiveSpan({ forceTransaction: true, name: 'profile_hub' }); + expect(startProfilingSpy).not.toHaveBeenCalled(); + }); + + it('sends as profile_chunk envelope type', async () => { + // @ts-expect-error we just mock the return type and ignore the signature + jest.spyOn(CpuProfilerBindings, 'stopProfiling').mockImplementation(() => { + return { + samples: [ + { + stack_id: 0, + thread_id: '0', + elapsed_since_start_ns: '10', + }, + { + stack_id: 0, + thread_id: '0', + elapsed_since_start_ns: '10', + }, + ], + measurements: {}, + stacks: [[0]], + frames: [], + resources: [], + profiler_logging_mode: 'lazy', + }; + }); + + const [client, transport] = makeContinuousProfilingClient(); + Sentry.setCurrentClient(client); + client.init(); + + const transportSpy = jest.spyOn(transport, 'send').mockReturnValue(Promise.resolve({})); + + const integration = client.getIntegrationByName('ProfilingIntegration'); + if (!integration) { + throw new Error('Profiling integration not found'); + } + integration._profiler.start(); + jest.advanceTimersByTime(1000); + integration._profiler.stop(); + jest.advanceTimersByTime(1000); + + expect(transportSpy.mock.calls?.[0]?.[0]?.[1]?.[0]?.[0].type).toBe('profile_chunk'); + }); +}); + +describe('span profiling mode', () => { + it.each([ + ['profilesSampleRate=1', makeClientOptions({ profilesSampleRate: 1 })], + ['profilesSampler is defined', makeClientOptions({ profilesSampler: () => 1 })], + ])('%s', async (_label, options) => { + const logSpy = jest.spyOn(logger, 'log'); + const client = new Sentry.NodeClient({ + ...options, + dsn: 'https://7fa19397baaf433f919fbe02228d5470@o1137848.ingest.sentry.io/6625302', + tracesSampleRate: 1, + transport: _opts => + Sentry.makeNodeTransport({ + url: 'https://7fa19397baaf433f919fbe02228d5470@o1137848.ingest.sentry.io/6625302', + recordDroppedEvent: () => { + return undefined; + }, + }), + integrations: [_nodeProfilingIntegration()], + }); + + Sentry.setCurrentClient(client); + client.init(); + + const startProfilingSpy = jest.spyOn(CpuProfilerBindings, 'startProfiling'); + const transport = client.getTransport(); + + if (!transport) { + throw new Error('Transport not found'); + } + + jest.spyOn(transport, 'send').mockReturnValue(Promise.resolve({})); + Sentry.startInactiveSpan({ forceTransaction: true, name: 'profile_hub' }); + + expect(startProfilingSpy).toHaveBeenCalled(); + + const integration = client.getIntegrationByName('ProfilingIntegration'); + + if (!integration) { + throw new Error('Profiling integration not found'); + } + + integration._profiler.start(); + expect(logSpy).toHaveBeenLastCalledWith('[Profiling] Profiler was never attached to the client.'); + }); +}); +describe('continuous profiling mode', () => { + it.each([ + ['profilesSampleRate=0', makeClientOptions({ profilesSampleRate: 0 })], + ['profilesSampleRate=undefined', makeClientOptions({ profilesSampleRate: undefined })], + // @ts-expect-error test invalid value + ['profilesSampleRate=null', makeClientOptions({ profilesSampleRate: null })], + [ + 'profilesSampler is not defined and profilesSampleRate is not set', + makeClientOptions({ profilesSampler: undefined, profilesSampleRate: 0 }), + ], + ])('%s', async (_label, options) => { + const client = new Sentry.NodeClient({ + ...options, + dsn: 'https://7fa19397baaf433f919fbe02228d5470@o1137848.ingest.sentry.io/6625302', + tracesSampleRate: 1, + transport: _opts => + Sentry.makeNodeTransport({ + url: 'https://7fa19397baaf433f919fbe02228d5470@o1137848.ingest.sentry.io/6625302', + recordDroppedEvent: () => { + return undefined; + }, + }), + integrations: [_nodeProfilingIntegration()], + }); + + Sentry.setCurrentClient(client); + client.init(); + + const startProfilingSpy = jest.spyOn(CpuProfilerBindings, 'startProfiling'); + const transport = client.getTransport(); + + if (!transport) { + throw new Error('Transport not found'); + } + + jest.spyOn(transport, 'send').mockReturnValue(Promise.resolve({})); + + const integration = client.getIntegrationByName('ProfilingIntegration'); + if (!integration) { + throw new Error('Profiling integration not found'); + } + integration._profiler.start(); + const callCount = startProfilingSpy.mock.calls.length; + expect(startProfilingSpy).toHaveBeenCalled(); + + Sentry.startInactiveSpan({ forceTransaction: true, name: 'profile_hub' }); + expect(startProfilingSpy).toHaveBeenCalledTimes(callCount); + }); +}); diff --git a/packages/types/src/envelope.ts b/packages/types/src/envelope.ts index d7089fbb0225..29e8fc123b16 100644 --- a/packages/types/src/envelope.ts +++ b/packages/types/src/envelope.ts @@ -4,7 +4,7 @@ import type { ClientReport } from './clientreport'; import type { DsnComponents } from './dsn'; import type { Event } from './event'; import type { FeedbackEvent, UserFeedback } from './feedback'; -import type { Profile } from './profiling'; +import type { Profile, ProfileChunk } from './profiling'; import type { ReplayEvent, ReplayRecordingData } from './replay'; import type { SdkInfo } from './sdkinfo'; import type { SerializedSession, SessionAggregates } from './session'; @@ -36,6 +36,7 @@ export type EnvelopeItemType = | 'attachment' | 'event' | 'profile' + | 'profile_chunk' | 'replay_event' | 'replay_recording' | 'check_in' @@ -79,8 +80,9 @@ type ClientReportItemHeaders = { type: 'client_report' }; type ReplayEventItemHeaders = { type: 'replay_event' }; type ReplayRecordingItemHeaders = { type: 'replay_recording'; length: number }; type CheckInItemHeaders = { type: 'check_in' }; -type StatsdItemHeaders = { type: 'statsd'; length: number }; type ProfileItemHeaders = { type: 'profile' }; +type ProfileChunkItemHeaders = { type: 'profile_chunk' }; +type StatsdItemHeaders = { type: 'statsd'; length: number }; type SpanItemHeaders = { type: 'span' }; export type EventItem = BaseEnvelopeItem; @@ -96,6 +98,7 @@ type ReplayRecordingItem = BaseEnvelopeItem; export type FeedbackItem = BaseEnvelopeItem; export type ProfileItem = BaseEnvelopeItem; +export type ProfileChunkItem = BaseEnvelopeItem; export type SpanItem = BaseEnvelopeItem>; export type EventEnvelopeHeaders = { event_id: string; sent_at: string; trace?: DynamicSamplingContext }; @@ -116,13 +119,16 @@ export type ReplayEnvelope = [ReplayEnvelopeHeaders, [ReplayEventItem, ReplayRec export type CheckInEnvelope = BaseEnvelope; export type StatsdEnvelope = BaseEnvelope; export type SpanEnvelope = BaseEnvelope; +export type ProfileChunkEnvelope = BaseEnvelope; export type Envelope = | EventEnvelope | SessionEnvelope | ClientReportEnvelope + | ProfileChunkEnvelope | ReplayEnvelope | CheckInEnvelope | StatsdEnvelope | SpanEnvelope; + export type EnvelopeItem = Envelope[1][number]; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index c90b7841f9ff..8fbfd37e95ab 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -45,6 +45,7 @@ export type { StatsdItem, StatsdEnvelope, ProfileItem, + ProfileChunkEnvelope, SpanEnvelope, SpanItem, } from './envelope'; @@ -69,7 +70,9 @@ export type { ThreadCpuStack, ThreadCpuFrame, ThreadCpuProfile, + ContinuousThreadCpuProfile, Profile, + ProfileChunk, } from './profiling'; export type { ReplayEvent, ReplayRecordingData, ReplayRecordingMode } from './replay'; export type { diff --git a/packages/types/src/profiling.ts b/packages/types/src/profiling.ts index 3650500fcd7b..5161b6b64b2e 100644 --- a/packages/types/src/profiling.ts +++ b/packages/types/src/profiling.ts @@ -12,6 +12,13 @@ export interface ThreadCpuSample { elapsed_since_start_ns: string; } +export interface ContinuousThreadCpuSample { + stack_id: StackId; + thread_id: ThreadId; + queue_address?: string; + timestamp: number; +} + export type ThreadCpuStack = FrameId[]; export type ThreadCpuFrame = { @@ -34,7 +41,37 @@ export interface ThreadCpuProfile { queue_metadata?: Record; } -export interface Profile { +export interface ContinuousThreadCpuProfile { + samples: ContinuousThreadCpuSample[]; + stacks: ThreadCpuStack[]; + frames: ThreadCpuFrame[]; + thread_metadata: Record; + queue_metadata?: Record; +} + +interface BaseProfile { + timestamp: string; + version: string; + release: string; + environment: string; + platform: string; + profile: T; + debug_meta?: { + images: DebugImage[]; + }; + measurements?: Record< + string, + { + unit: MeasurementUnit; + values: { + elapsed_since_start_ns: number; + value: number; + }[]; + } + >; +} + +export interface Profile extends BaseProfile { event_id: string; version: string; os: { @@ -86,3 +123,8 @@ export interface Profile { } >; } + +export interface ProfileChunk extends BaseProfile { + chunk_id: string; + profiler_id: string; +} diff --git a/packages/utils/src/envelope.ts b/packages/utils/src/envelope.ts index 17c40bed92ad..8bf29788edf0 100644 --- a/packages/utils/src/envelope.ts +++ b/packages/utils/src/envelope.ts @@ -217,6 +217,7 @@ const ITEM_TYPE_TO_DATA_CATEGORY_MAP: Record = { client_report: 'internal', user_report: 'default', profile: 'profile', + profile_chunk: 'profile', replay_event: 'replay', replay_recording: 'replay', check_in: 'monitor',