diff --git a/.craft.yml b/.craft.yml index dbde338..04ebe92 100644 --- a/.craft.yml +++ b/.craft.yml @@ -5,5 +5,5 @@ targets: - name: npm - name: registry sdks: - npm:@sentry-internal/profiling-node-binaries: + npm:@sentry-internal/node-cpu-profiler: - name: github diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d9d7a8e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,38 @@ +name: "Action: Prepare Release" +on: + workflow_dispatch: + inputs: + version: + description: Version to release + required: true + force: + description: Force a release even when there are release-blockers (optional) + required: false + merge_target: + description: Target branch to merge into. Uses the default branch as a fallback (optional) + required: false + default: master +jobs: + release: + runs-on: ubuntu-20.04 + name: 'Release a new version' + steps: + - name: Get auth token + id: token + uses: actions/create-github-app-token@5d869da34e18e7287c1daad50e0b8ea0f506ce69 # v1.11.0 + with: + app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }} + private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }} + - uses: actions/checkout@v4 + with: + token: ${{ steps.token.outputs.token }} + fetch-depth: 0 + - name: Prepare release + uses: getsentry/action-prepare-release@v1 + env: + GITHUB_TOKEN: ${{ steps.token.outputs.token }} + with: + version: ${{ github.event.inputs.version }} + force: ${{ github.event.inputs.force }} + merge_target: ${{ github.event.inputs.merge_target }} + craft_config_from_merge_target: true diff --git a/CHANGELOG b/CHANGELOG index 012426c..c09a5d2 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,7 +1,10 @@ -## This is an old changelog. +## 2.0.0 -The profiling-node package has since been migrated to sentry-javascript monorepo. Any changes to this package made after -the migration are now tracked in the root CHANGELOG.md. +The profiling-node binaries were moved out of the JavaScript monorepo and into their own repository. + +## Repository Change. + +The profiling-node package was migrated to sentry-javascript monorepo. ## 1.3.3 diff --git a/README.md b/README.md new file mode 100644 index 0000000..2e48747 --- /dev/null +++ b/README.md @@ -0,0 +1,10 @@ +

+ + Sentry + +

+ +# Sentry JavaScript Node CPU Profiler + +This is an internal package that contains the CPU profiler and native binaries +used by `@sentry/profiling-node`. diff --git a/package.json b/package.json index f0bc612..8b2de66 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "@sentry-internal/profiling-node-binaries", + "name": "@sentry-internal/node-cpu-profiler", "version": "2.0.0", "description": "Binaries for Sentry Node Profiling", "repository": "git://github.com/getsentry/sentry-javascript-profiling-node-binaries.git", @@ -40,8 +40,7 @@ "build:bindings:arm64": "node-gyp build --arch=arm64 && node scripts/copy-target.js", "build:dev": "yarn clean && yarn build:bindings:configure && yarn build", "build:tarball": "npm pack", - "pretest": "node ./test/prepare.js", - "test": "vitest run --testTimeout 60000" + "test": "node ./test/prepare.js && vitest run --testTimeout 60000" }, "dependencies": { "detect-libc": "^2.0.2", diff --git a/test/bindings.test.ts b/test/bindings.test.ts index 7705ec0..8b2924f 100644 --- a/test/bindings.test.ts +++ b/test/bindings.test.ts @@ -1,364 +1,33 @@ +// Contains unit tests for some of the C++ bindings. These functions +// are exported on the private bindings object, so we can test them and +// they should not be used outside of this file. + // eslint-disable-next-line import/no-unresolved -import type { RawChunkCpuProfile, RawThreadCpuProfile } from '@sentry-internal/profiling-node-binaries'; -// eslint-disable-next-line import/no-unresolved -import { CpuProfilerBindings, PrivateCpuProfilerBindings,ProfileFormat } from '@sentry-internal/profiling-node-binaries'; +import { PrivateCpuProfilerBindings } from '@sentry-internal/node-cpu-profiler'; +import { platform } from 'os'; import { describe, expect,test } from 'vitest'; -function fail(message: string): never { - throw new Error(message); -} - -const fibonacci = (n: number): number => { - if (n <= 1) { - return n; - } - return fibonacci(n - 1) + fibonacci(n - 2); -}; - -const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); -const profiled = async (name: string, fn: () => void) => { - CpuProfilerBindings.startProfiling(name); - await fn(); - return CpuProfilerBindings.stopProfiling(name, ProfileFormat.THREAD); -}; - -const assertValidSamplesAndStacks = ( - stacks: RawChunkCpuProfile['stacks'], - samples: RawChunkCpuProfile['samples'] | RawThreadCpuProfile['samples'], -) => { - expect(stacks.length).toBeGreaterThan(0); - expect(samples.length).toBeGreaterThan(0); - expect(stacks.length <= samples.length).toBe(true); - - for (const sample of samples) { - if (sample.stack_id === undefined) { - throw new Error(`Sample ${JSON.stringify(sample)} has not stack id associated`); - } - if (!stacks[sample.stack_id]) { - throw new Error(`Failed to find stack for sample: ${JSON.stringify(sample)}`); - } - expect(stacks[sample.stack_id]).not.toBe(undefined); - } - - for (const stack of stacks) { - expect(stack).not.toBe(undefined); - } -}; - -const isValidMeasurementValue = (v: any) => { - if (isNaN(v)) return false; - return typeof v === 'number' && v > 0; -}; - -const assertValidMeasurements = (measurement: RawThreadCpuProfile['measurements']['memory_footprint'] | undefined) => { - if (!measurement) { - throw new Error('Measurement is undefined'); - } - expect(measurement).not.toBe(undefined); - expect(typeof measurement.unit).toBe('string'); - expect(measurement.unit.length).toBeGreaterThan(0); - - for (let i = 0; i < measurement.values.length; i++) { - expect(measurement?.values?.[i]?.elapsed_since_start_ns).toBeGreaterThan(0); - expect(measurement?.values?.[i]?.value).toBeGreaterThan(0); - } -}; - -describe('Bindings', () => { - describe('Private bindings', () => { - test('does not crash if collect resources is false', async () => { - PrivateCpuProfilerBindings.startProfiling!('profiled-program'); - await wait(100); - expect(() => { - const profile = PrivateCpuProfilerBindings.stopProfiling!('profiled-program', 0, 0, false); - if (!profile) throw new Error('No profile'); - }).not.toThrow(); - }); - - test('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.'); - }); - - test('collects resources', async () => { - PrivateCpuProfilerBindings.startProfiling!('profiled-program'); - await wait(100); - - const profile = PrivateCpuProfilerBindings.stopProfiling!('profiled-program', 0, 0, true); - if (!profile) throw new Error('No profile'); - - expect(profile.resources.length).toBeGreaterThan(0); - - expect(new Set(profile.resources).size).toBe(profile.resources.length); - - for (const resource of profile.resources) { - expect(typeof resource).toBe('string'); - expect(resource).not.toBe(undefined); - } - }); - - test('does not collect resources', async () => { - PrivateCpuProfilerBindings.startProfiling!('profiled-program'); - await wait(100); - - const profile = PrivateCpuProfilerBindings.stopProfiling!('profiled-program', 0, 0, false); - if (!profile) throw new Error('No profile'); - - expect(profile.resources.length).toBe(0); - }); - }); - - describe('Profiler bindings', () => { - test('exports profiler binding methods', () => { - expect(typeof CpuProfilerBindings['startProfiling']).toBe('function'); - expect(typeof CpuProfilerBindings['stopProfiling']).toBe('function'); - }); - - test('profiles a program', async () => { - const profile = await profiled('profiled-program', async () => { - await wait(100); - }); - - if (!profile) fail('Profile is null'); - - assertValidSamplesAndStacks(profile.stacks, profile.samples); - }); - - test('adds thread_id info', async () => { - const profile = await profiled('profiled-program', async () => { - await wait(100); - }); - - if (!profile) fail('Profile is null'); - const samples = profile.samples; - - if (!samples.length) { - throw new Error('No samples'); - } - for (const sample of samples) { - expect(sample.thread_id).toBe('0'); - } - }); - - test('caps stack depth at 128', async () => { - const recurseToDepth = async (depth: number): Promise => { - if (depth === 0) { - // Wait a bit to make sure stack gets sampled here - await wait(1000); - return 0; - } - const v = await recurseToDepth(depth - 1); - return v; - }; - - const profile = await profiled('profiled-program', async () => { - await recurseToDepth(256); - }); - - if (!profile) fail('Profile is null'); - - for (const stack of profile.stacks) { - expect(stack.length).toBeLessThanOrEqual(128); - } - }); - - test('does not record two profiles when titles match', () => { - CpuProfilerBindings.startProfiling('same-title'); - CpuProfilerBindings.startProfiling('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); - }); - - test('multiple calls with same title', () => { - CpuProfilerBindings.startProfiling('same-title'); - expect(() => { - CpuProfilerBindings.stopProfiling('same-title', 0); - CpuProfilerBindings.stopProfiling('same-title', 0); - }).not.toThrow(); - }); - - test('does not crash if stopTransaction is called before startTransaction', () => { - expect(CpuProfilerBindings.stopProfiling('does not exist', 0)).toBe(null); - }); - - test('does crash if name is invalid', () => { - expect(() => CpuProfilerBindings.stopProfiling('', 0)).toThrow(); - // @ts-expect-error test invalid input - expect(() => CpuProfilerBindings.stopProfiling(undefined)).toThrow(); - // @ts-expect-error test invalid input - expect(() => CpuProfilerBindings.stopProfiling(null)).toThrow(); - // @ts-expect-error test invalid input - expect(() => CpuProfilerBindings.stopProfiling({})).toThrow(); - }); - - test('does not throw if stopTransaction is called before startTransaction', () => { - expect(CpuProfilerBindings.stopProfiling('does not exist', 0)).toBe(null); - expect(() => CpuProfilerBindings.stopProfiling('does not exist', 0)).not.toThrow(); - }); - - test('compiles with eager logging by default', async () => { - const profile = await profiled('profiled-program', async () => { - await wait(100); - }); - - if (!profile) fail('Profile is null'); - expect(profile.profiler_logging_mode).toBe('eager'); - }); - - test('chunk format type', async () => { - const fn = async () => { - await wait(1000); - fibonacci(36); - await wait(1000); - }; - - CpuProfilerBindings.startProfiling('non nullable stack'); - await fn(); - const profile = CpuProfilerBindings.stopProfiling('non nullable stack', ProfileFormat.CHUNK); - - 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(); - // No older than a minute and not in the future. Timestamp is in seconds so convert to ms - // as the constructor expects ms. - expect(new Date((sample.timestamp as number) * 1e3).getTime()).toBeGreaterThan(Date.now() - 60 * 1e3); - expect(new Date((sample.timestamp as number) * 1e3).getTime()).toBeLessThanOrEqual(Date.now()); - } - }); - - test('stacks are not null', async () => { - const profile = await profiled('non nullable stack', async () => { - await wait(1000); - fibonacci(36); - await wait(1000); - }); - - if (!profile) fail('Profile is null'); - assertValidSamplesAndStacks(profile.stacks, profile.samples); - }); - - test('samples at ~99hz', async () => { - CpuProfilerBindings.startProfiling('profile'); - await wait(100); - const profile = CpuProfilerBindings.stopProfiling('profile', 0); - - if (!profile) fail('Profile is null'); - - // Exception for macos and windows - we seem to get way less samples there, but I'm not sure if that's due to poor - // performance of the actions runner, machine or something else. This needs more investigation to determine - // the cause of low sample count. https://github.com/actions/runner-images/issues/1336 seems relevant. - if (process.platform === 'darwin' || process.platform === 'win32') { - if (profile.samples.length < 2) { - fail(`Only ${profile.samples.length} samples obtained on ${process.platform}, expected at least 2`); - } - } else { - if (profile.samples.length < 6) { - fail(`Only ${profile.samples.length} samples obtained on ${process.platform}, expected at least 6`); - } - } - if (profile.samples.length > 15) { - fail(`Too many samples on ${process.platform}, got ${profile.samples.length}`); - } - }); - - test('collects memory footprint', async () => { - CpuProfilerBindings.startProfiling('profile'); - await wait(1000); - const profile = CpuProfilerBindings.stopProfiling('profile', 0); - - const heap_usage = profile?.measurements['memory_footprint']; - if (!heap_usage) { - throw new Error('memory_footprint is null'); - } - expect(heap_usage.values.length).toBeGreaterThan(6); - expect(heap_usage.values.length).toBeLessThanOrEqual(11); - expect(heap_usage.unit).toBe('byte'); - expect(heap_usage.values.every(v => isValidMeasurementValue(v.value))).toBe(true); - assertValidMeasurements(profile.measurements['memory_footprint']); - }); - - test('collects cpu usage', async () => { - CpuProfilerBindings.startProfiling('profile'); - await wait(1000); - const profile = CpuProfilerBindings.stopProfiling('profile', 0); - - const cpu_usage = profile?.measurements['cpu_usage']; - if (!cpu_usage) { - throw new Error('cpu_usage is null'); - } - expect(cpu_usage.values.length).toBeGreaterThan(6); - expect(cpu_usage.values.length).toBeLessThanOrEqual(11); - expect(cpu_usage.values.every(v => isValidMeasurementValue(v.value))).toBe(true); - expect(cpu_usage.unit).toBe('percent'); - assertValidMeasurements(profile.measurements['cpu_usage']); - }); - - test('does not overflow measurement buffer if profile runs longer than 30s', async () => { - CpuProfilerBindings.startProfiling('profile'); - await wait(35000); - 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); - }); - - // eslint-disable-next-line @sentry-internal/sdk/no-skipped-tests - test.skip('includes deopt reason', async () => { - // https://github.com/petkaantonov/bluebird/wiki/Optimization-killers#52-the-object-being-iterated-is-not-a-simple-enumerable - function iterateOverLargeHashTable() { - const table: Record = {}; - for (let i = 0; i < 1e5; i++) { - table[i] = i; - } - // eslint-disable-next-line - for (const _ in table) { - } - } - - const profile = await profiled('profiled-program', async () => { - iterateOverLargeHashTable(); - }); - - expect(profile).not.toBe(null); - - // @ts-expect-error test invalid input - const hasDeoptimizedFrame = profile?.frames.some(f => f.deopt_reasons?.length > 0); - expect(hasDeoptimizedFrame).toBe(true); - }); - - test('does not crash if the native startProfiling function is not available', async () => { - const original = PrivateCpuProfilerBindings.startProfiling; - PrivateCpuProfilerBindings.startProfiling = undefined; - - expect(() => { - CpuProfilerBindings.startProfiling('profiled-program'); - }).not.toThrow(); - - PrivateCpuProfilerBindings.startProfiling = original; - }); - - test('does not crash if the native stopProfiling function is not available', async () => { - // eslint-disable-next-line @typescript-eslint/unbound-method - const original = PrivateCpuProfilerBindings.stopProfiling; - PrivateCpuProfilerBindings.stopProfiling = undefined; - - expect(() => { - CpuProfilerBindings.stopProfiling('profiled-program', 0); - }).not.toThrow(); - - PrivateCpuProfilerBindings.stopProfiling = original; - }); +const cases = [ + ['/Users/jonas/code/node_modules/@scope/package/file.js', '@scope.package:file'], + ['/Users/jonas/code/node_modules/package/dir/file.js', 'package.dir:file'], + ['/Users/jonas/code/node_modules/package/file.js', 'package:file'], + ['/Users/jonas/code/src/file.js', 'Users.jonas.code.src:file'], + + // Preserves non .js extensions + ['/Users/jonas/code/src/file.ts', 'Users.jonas.code.src:file.ts'], + // No extension + ['/Users/jonas/code/src/file', 'Users.jonas.code.src:file'], + // Edge cases that shouldn't happen in practice, but try and handle them so we don't crash + ['/Users/jonas/code/src/file.js', 'Users.jonas.code.src:file'], + ['', ''], +]; + +describe('GetFrameModule', () => { + test.each( + platform() === 'win32' + ? cases.map(([abs_path, expected]) => [abs_path ? `C:${abs_path.replace(/\//g, '\\')}` : '', expected]) + : cases, + )('%s => %s', (abs_path: string, expected: string) => { + expect(PrivateCpuProfilerBindings.getFrameModule(abs_path)).toBe(expected); }); }); diff --git a/test/prepare.js b/test/prepare.js index bf6beaf..8831992 100644 --- a/test/prepare.js +++ b/test/prepare.js @@ -1,8 +1,20 @@ +/** + * We install the tarball in this directory to test the package. + */ const { execSync, spawnSync } = require('node:child_process'); -const { rmSync, writeFileSync } = require('node:fs'); +const { rmSync, writeFileSync, existsSync } = require('node:fs'); const { join } = require('node:path'); -const pkgJson = require('../package.json') +const pkgJson = require('../package.json'); +const normalizedName = pkgJson.name.replace('@', '').replace('/', '-'); + +const tarball = join(__dirname, '..', `${normalizedName}-${pkgJson.version}.tgz`); + +if (!existsSync(tarball)) { + console.error(`Tarball not found: '${tarball}'`); + console.error(`Run 'yarn build && yarn build:tarball' first`); + process.exit(1); +} console.log('Clearing node_modules...'); rmSync(join(__dirname, 'node_modules'), { recursive: true, force: true }); @@ -19,10 +31,10 @@ rmSync(tmpDir, { recursive: true, force: true }); console.log('Writing package.json...'); writeFileSync(join(__dirname, 'package.json'), `{ - "name": "profiling-node-binaries-test", + "name": "node-cpu-profiler-test", "license": "MIT", "dependencies": { - "@sentry-internal/profiling-node-binaries": "file:../sentry-internal-profiling-node-binaries-${pkgJson.version}.tgz" + "@sentry-internal/node-cpu-profiler": "file:../${normalizedName}-${pkgJson.version}.tgz" } }`); diff --git a/test/profiler.test.ts b/test/profiler.test.ts new file mode 100644 index 0000000..3935bac --- /dev/null +++ b/test/profiler.test.ts @@ -0,0 +1,364 @@ +// eslint-disable-next-line import/no-unresolved +import type { RawChunkCpuProfile, RawThreadCpuProfile } from '@sentry-internal/node-cpu-profiler'; +// eslint-disable-next-line import/no-unresolved +import { CpuProfilerBindings, PrivateCpuProfilerBindings,ProfileFormat } from '@sentry-internal/node-cpu-profiler'; +import { describe, expect,test } from 'vitest'; + +function fail(message: string): never { + throw new Error(message); +} + +const fibonacci = (n: number): number => { + if (n <= 1) { + return n; + } + return fibonacci(n - 1) + fibonacci(n - 2); +}; + +const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); +const profiled = async (name: string, fn: () => void) => { + CpuProfilerBindings.startProfiling(name); + await fn(); + return CpuProfilerBindings.stopProfiling(name, ProfileFormat.THREAD); +}; + +const assertValidSamplesAndStacks = ( + stacks: RawChunkCpuProfile['stacks'], + samples: RawChunkCpuProfile['samples'] | RawThreadCpuProfile['samples'], +) => { + expect(stacks.length).toBeGreaterThan(0); + expect(samples.length).toBeGreaterThan(0); + expect(stacks.length <= samples.length).toBe(true); + + for (const sample of samples) { + if (sample.stack_id === undefined) { + throw new Error(`Sample ${JSON.stringify(sample)} has not stack id associated`); + } + if (!stacks[sample.stack_id]) { + throw new Error(`Failed to find stack for sample: ${JSON.stringify(sample)}`); + } + expect(stacks[sample.stack_id]).not.toBe(undefined); + } + + for (const stack of stacks) { + expect(stack).not.toBe(undefined); + } +}; + +const isValidMeasurementValue = (v: any) => { + if (isNaN(v)) return false; + return typeof v === 'number' && v > 0; +}; + +const assertValidMeasurements = (measurement: RawThreadCpuProfile['measurements']['memory_footprint'] | undefined) => { + if (!measurement) { + throw new Error('Measurement is undefined'); + } + expect(measurement).not.toBe(undefined); + expect(typeof measurement.unit).toBe('string'); + expect(measurement.unit.length).toBeGreaterThan(0); + + for (let i = 0; i < measurement.values.length; i++) { + expect(measurement?.values?.[i]?.elapsed_since_start_ns).toBeGreaterThan(0); + expect(measurement?.values?.[i]?.value).toBeGreaterThan(0); + } +}; + +describe('Bindings', () => { + describe('Private bindings', () => { + test('does not crash if collect resources is false', async () => { + PrivateCpuProfilerBindings.startProfiling!('profiled-program'); + await wait(100); + expect(() => { + const profile = PrivateCpuProfilerBindings.stopProfiling!('profiled-program', 0, 0, false); + if (!profile) throw new Error('No profile'); + }).not.toThrow(); + }); + + test('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.'); + }); + + test('collects resources', async () => { + PrivateCpuProfilerBindings.startProfiling!('profiled-program'); + await wait(100); + + const profile = PrivateCpuProfilerBindings.stopProfiling!('profiled-program', 0, 0, true); + if (!profile) throw new Error('No profile'); + + expect(profile.resources.length).toBeGreaterThan(0); + + expect(new Set(profile.resources).size).toBe(profile.resources.length); + + for (const resource of profile.resources) { + expect(typeof resource).toBe('string'); + expect(resource).not.toBe(undefined); + } + }); + + test('does not collect resources', async () => { + PrivateCpuProfilerBindings.startProfiling!('profiled-program'); + await wait(100); + + const profile = PrivateCpuProfilerBindings.stopProfiling!('profiled-program', 0, 0, false); + if (!profile) throw new Error('No profile'); + + expect(profile.resources.length).toBe(0); + }); + }); + + describe('Profiler bindings', () => { + test('exports profiler binding methods', () => { + expect(typeof CpuProfilerBindings['startProfiling']).toBe('function'); + expect(typeof CpuProfilerBindings['stopProfiling']).toBe('function'); + }); + + test('profiles a program', async () => { + const profile = await profiled('profiled-program', async () => { + await wait(100); + }); + + if (!profile) fail('Profile is null'); + + assertValidSamplesAndStacks(profile.stacks, profile.samples); + }); + + test('adds thread_id info', async () => { + const profile = await profiled('profiled-program', async () => { + await wait(100); + }); + + if (!profile) fail('Profile is null'); + const samples = profile.samples; + + if (!samples.length) { + throw new Error('No samples'); + } + for (const sample of samples) { + expect(sample.thread_id).toBe('0'); + } + }); + + test('caps stack depth at 128', async () => { + const recurseToDepth = async (depth: number): Promise => { + if (depth === 0) { + // Wait a bit to make sure stack gets sampled here + await wait(1000); + return 0; + } + const v = await recurseToDepth(depth - 1); + return v; + }; + + const profile = await profiled('profiled-program', async () => { + await recurseToDepth(256); + }); + + if (!profile) fail('Profile is null'); + + for (const stack of profile.stacks) { + expect(stack.length).toBeLessThanOrEqual(128); + } + }); + + test('does not record two profiles when titles match', () => { + CpuProfilerBindings.startProfiling('same-title'); + CpuProfilerBindings.startProfiling('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); + }); + + test('multiple calls with same title', () => { + CpuProfilerBindings.startProfiling('same-title'); + expect(() => { + CpuProfilerBindings.stopProfiling('same-title', 0); + CpuProfilerBindings.stopProfiling('same-title', 0); + }).not.toThrow(); + }); + + test('does not crash if stopTransaction is called before startTransaction', () => { + expect(CpuProfilerBindings.stopProfiling('does not exist', 0)).toBe(null); + }); + + test('does crash if name is invalid', () => { + expect(() => CpuProfilerBindings.stopProfiling('', 0)).toThrow(); + // @ts-expect-error test invalid input + expect(() => CpuProfilerBindings.stopProfiling(undefined)).toThrow(); + // @ts-expect-error test invalid input + expect(() => CpuProfilerBindings.stopProfiling(null)).toThrow(); + // @ts-expect-error test invalid input + expect(() => CpuProfilerBindings.stopProfiling({})).toThrow(); + }); + + test('does not throw if stopTransaction is called before startTransaction', () => { + expect(CpuProfilerBindings.stopProfiling('does not exist', 0)).toBe(null); + expect(() => CpuProfilerBindings.stopProfiling('does not exist', 0)).not.toThrow(); + }); + + test('compiles with eager logging by default', async () => { + const profile = await profiled('profiled-program', async () => { + await wait(100); + }); + + if (!profile) fail('Profile is null'); + expect(profile.profiler_logging_mode).toBe('eager'); + }); + + test('chunk format type', async () => { + const fn = async () => { + await wait(1000); + fibonacci(36); + await wait(1000); + }; + + CpuProfilerBindings.startProfiling('non nullable stack'); + await fn(); + const profile = CpuProfilerBindings.stopProfiling('non nullable stack', ProfileFormat.CHUNK); + + 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(); + // No older than a minute and not in the future. Timestamp is in seconds so convert to ms + // as the constructor expects ms. + expect(new Date((sample.timestamp as number) * 1e3).getTime()).toBeGreaterThan(Date.now() - 60 * 1e3); + expect(new Date((sample.timestamp as number) * 1e3).getTime()).toBeLessThanOrEqual(Date.now()); + } + }); + + test('stacks are not null', async () => { + const profile = await profiled('non nullable stack', async () => { + await wait(1000); + fibonacci(36); + await wait(1000); + }); + + if (!profile) fail('Profile is null'); + assertValidSamplesAndStacks(profile.stacks, profile.samples); + }); + + test('samples at ~99hz', async () => { + CpuProfilerBindings.startProfiling('profile'); + await wait(100); + const profile = CpuProfilerBindings.stopProfiling('profile', 0); + + if (!profile) fail('Profile is null'); + + // Exception for macos and windows - we seem to get way less samples there, but I'm not sure if that's due to poor + // performance of the actions runner, machine or something else. This needs more investigation to determine + // the cause of low sample count. https://github.com/actions/runner-images/issues/1336 seems relevant. + if (process.platform === 'darwin' || process.platform === 'win32') { + if (profile.samples.length < 2) { + fail(`Only ${profile.samples.length} samples obtained on ${process.platform}, expected at least 2`); + } + } else { + if (profile.samples.length < 6) { + fail(`Only ${profile.samples.length} samples obtained on ${process.platform}, expected at least 6`); + } + } + if (profile.samples.length > 15) { + fail(`Too many samples on ${process.platform}, got ${profile.samples.length}`); + } + }); + + test('collects memory footprint', async () => { + CpuProfilerBindings.startProfiling('profile'); + await wait(1000); + const profile = CpuProfilerBindings.stopProfiling('profile', 0); + + const heap_usage = profile?.measurements['memory_footprint']; + if (!heap_usage) { + throw new Error('memory_footprint is null'); + } + expect(heap_usage.values.length).toBeGreaterThan(6); + expect(heap_usage.values.length).toBeLessThanOrEqual(11); + expect(heap_usage.unit).toBe('byte'); + expect(heap_usage.values.every(v => isValidMeasurementValue(v.value))).toBe(true); + assertValidMeasurements(profile.measurements['memory_footprint']); + }); + + test('collects cpu usage', async () => { + CpuProfilerBindings.startProfiling('profile'); + await wait(1000); + const profile = CpuProfilerBindings.stopProfiling('profile', 0); + + const cpu_usage = profile?.measurements['cpu_usage']; + if (!cpu_usage) { + throw new Error('cpu_usage is null'); + } + expect(cpu_usage.values.length).toBeGreaterThan(6); + expect(cpu_usage.values.length).toBeLessThanOrEqual(11); + expect(cpu_usage.values.every(v => isValidMeasurementValue(v.value))).toBe(true); + expect(cpu_usage.unit).toBe('percent'); + assertValidMeasurements(profile.measurements['cpu_usage']); + }); + + test('does not overflow measurement buffer if profile runs longer than 30s', async () => { + CpuProfilerBindings.startProfiling('profile'); + await wait(35000); + 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); + }); + + // eslint-disable-next-line @sentry-internal/sdk/no-skipped-tests + test.skip('includes deopt reason', async () => { + // https://github.com/petkaantonov/bluebird/wiki/Optimization-killers#52-the-object-being-iterated-is-not-a-simple-enumerable + function iterateOverLargeHashTable() { + const table: Record = {}; + for (let i = 0; i < 1e5; i++) { + table[i] = i; + } + // eslint-disable-next-line + for (const _ in table) { + } + } + + const profile = await profiled('profiled-program', async () => { + iterateOverLargeHashTable(); + }); + + expect(profile).not.toBe(null); + + // @ts-expect-error test invalid input + const hasDeoptimizedFrame = profile?.frames.some(f => f.deopt_reasons?.length > 0); + expect(hasDeoptimizedFrame).toBe(true); + }); + + test('does not crash if the native startProfiling function is not available', async () => { + const original = PrivateCpuProfilerBindings.startProfiling; + PrivateCpuProfilerBindings.startProfiling = undefined; + + expect(() => { + CpuProfilerBindings.startProfiling('profiled-program'); + }).not.toThrow(); + + PrivateCpuProfilerBindings.startProfiling = original; + }); + + test('does not crash if the native stopProfiling function is not available', async () => { + // eslint-disable-next-line @typescript-eslint/unbound-method + const original = PrivateCpuProfilerBindings.stopProfiling; + PrivateCpuProfilerBindings.stopProfiling = undefined; + + expect(() => { + CpuProfilerBindings.stopProfiling('profiled-program', 0); + }).not.toThrow(); + + PrivateCpuProfilerBindings.stopProfiling = original; + }); + }); +});