Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,35 @@ on:
- 'v*'

jobs:
bun-smoke:
strategy:
fail-fast: false
matrix:
include:
- triplet: linux-x64
runs-on: ubuntu-x64-small
- triplet: linux-arm64
runs-on: ubuntu-24.04-arm
- triplet: darwin-arm64
runs-on: macos-14
runs-on: ${{ matrix.runs-on }}
name: bun-smoke-${{ matrix.triplet }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: 'false'
- name: Set up Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # 6.2.0
with:
node-version: 24.x
package-manager-cache: false
- name: Set up Bun
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
- name: Install modules
run: yarn
- name: Run Bun smoke check
run: yarn bun:smoke

release:
runs-on: ubuntu-latest
permissions:
Expand All @@ -29,6 +58,7 @@ jobs:
prerelease: false

publish:
needs: bun-smoke
runs-on: ubuntu-latest
# Extra guard to make sure publishing is only on v* tags, which are protected.
if: startsWith(github.ref, 'refs/tags/v')
Expand Down
29 changes: 29 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,32 @@ jobs:
run: yarn build
- name: Run tests
run: yarn test

bun-smoke:
strategy:
fail-fast: false
matrix:
include:
- triplet: linux-x64
runs-on: ubuntu-x64-small
- triplet: linux-arm64
runs-on: ubuntu-24.04-arm
- triplet: darwin-arm64
runs-on: macos-14
runs-on: ${{ matrix.runs-on }}
name: bun-smoke-${{ matrix.triplet }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: 'false'
- name: Set up Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # 6.2.0
with:
node-version: 24.x
package-manager-cache: false
- name: Set up Bun
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
- name: Install modules
run: yarn
- name: Run Bun smoke check
run: yarn bun:smoke
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"lint": "eslint --cache --ignore-path ./.gitignore --ext .js,.jsx,.ts,.tsx .",
"lint:fix": "yarn lint --fix",
"test": "vitest",
"bun:smoke": "npm run build:cjs && npm run build:esm && bun ./tools/bun-smoke.mjs",
"test:cov": "vitest run --coverage",
"addscope": "node tools/packagejson name @pyroscope"
},
Expand All @@ -42,7 +43,7 @@
"url": "https://github.com/grafana/pyroscope-nodejs/issues"
},
"dependencies": {
"@datadog/pprof": "5.13.2",
"@datadog/pprof": "github:SamuelLHuber/pprof-nodejs#pyroscope-ts-bun-compat-2026-02-10",
"debug": "^4.4.3",
"p-limit": "^7.2.0",
"regenerator-runtime": "^0.14.1",
Expand Down
24 changes: 18 additions & 6 deletions src/profilers/continuous-profiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export class ContinuousProfiler<TStartArgs> {
readonly startArgs: TStartArgs;
private timer: NodeJS.Timeout | undefined;
private lastExport: Promise<void> | undefined;
private running = false;

constructor(input: ContinuousProfilerInput<TStartArgs>) {
this.exporter = input.exporter;
Expand All @@ -27,26 +28,30 @@ export class ContinuousProfiler<TStartArgs> {
}

public start(): void {
if (this.timer !== undefined) {
if (this.running) {
log('already started');
return;
}

log('start');
this.running = true;
this.profiler.start(this.startArgs);
this.scheduleProfilingRound();
}

public async stop(): Promise<void> {
if (this.timer === undefined) {
if (!this.running) {
log('already stopped');
return;
}

log('stopping');
this.running = false;

clearTimeout(this.timer);
this.timer = undefined;
if (this.timer !== undefined) {
clearTimeout(this.timer);
this.timer = undefined;
}

if (this.lastExport !== undefined) {
await this.lastExport;
Expand All @@ -68,8 +73,15 @@ export class ContinuousProfiler<TStartArgs> {
private scheduleProfilingRound() {
this.timer = setTimeout(() => {
setImmediate(() => {
void this.profilingRound();
this.scheduleProfilingRound();
void this.profilingRound()
.catch((error: unknown) => {
log(`failed to capture profile during round: ${String(error)}`);
})
.finally(() => {
if (this.running) {
this.scheduleProfilingRound();
}
});
});
}, this.flushIntervalMs);
}
Expand Down
2 changes: 1 addition & 1 deletion src/profilers/wall-profiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export class WallProfiler implements Profiler<WallProfilerStartArgs> {
log('start');

this.lastProfiledAt = new Date();
this.lastSamplingIntervalMicros = args.samplingDurationMs;
this.lastSamplingIntervalMicros = args.samplingIntervalMicros;
time.start({
sourceMapper: args.sourceMapper,
durationMillis: args.samplingDurationMs,
Expand Down
36 changes: 36 additions & 0 deletions test/continuous-profiler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import {describe, expect, it, vi} from 'vitest';

import {ProfileExport} from '../src/profile-exporter.js';
import {ContinuousProfiler} from '../src/profilers/continuous-profiler.js';
import {Profiler} from '../src/profilers/profiler.js';

describe('ContinuousProfiler', () => {
it('keeps running when a profiling round throws', async () => {
const exporter = {
export: vi.fn(async (_profileExport: ProfileExport) => {}),
};

const profiler: Profiler<void> = {
getLabels: () => ({}),
setLabels: () => {},
wrapWithLabels: (_labels, fn) => fn(),
start: () => {},
stop: () => null,
profile: () => {
throw new Error('transient profiling error');
},
};

const continuousProfiler = new ContinuousProfiler<void>({
exporter,
flushIntervalMs: 5,
profiler,
startArgs: undefined,
});

continuousProfiler.start();
await new Promise(resolve => setTimeout(resolve, 30));
await expect(continuousProfiler.stop()).resolves.toBeUndefined();
expect(exporter.export).not.toHaveBeenCalled();
});
});
29 changes: 29 additions & 0 deletions test/profiler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,35 @@ describe('common behaviour of profilers', () => {
expect(req.query.name).toBe('nodejs{}');
});

it('should set wall sampleRate from samplingIntervalMicros', async () => {
const firstRequest = new Promise<express.Request>((resolve) => {
const port = createBackend(
(req: express.Request, res: express.Response) => {
resolve(req);
res.send('ok');
}
);

port.then((p: number) => {
Pyroscope.init({
serverAddress: `http://localhost:${p}`,
appName: 'nodejs',
flushIntervalMs: 100,
wall: {
samplingDurationMs: 1000,
samplingIntervalMicros: 10000,
},
});
Pyroscope.startWallProfiling();
doWork(0.1);
});
});

const req = await firstRequest;
await Pyroscope.stopWallProfiling();
expect(req.query.sampleRate).toBe('100');
});

it('should call a server on startHeapProfiling and clear gracefully', async () => {
const firstRequest = new Promise<express.Request>((resolve) => {
const port = createBackend(
Expand Down
127 changes: 127 additions & 0 deletions tools/bun-smoke.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
#!/usr/bin/env bun

import express from 'express';
import Pyroscope from '../dist/esm/index.js';

function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}

async function listen(app) {
return await new Promise((resolve, reject) => {
const server = app.listen(0, () => resolve(server));
server.once('error', reject);
});
}

async function close(server) {
await new Promise((resolve, reject) => {
server.close(error => {
if (error) {
reject(error);
} else {
resolve();
}
});
});
}

async function waitFor(condition, timeoutMs, intervalMs = 50) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (condition()) {
return;
}
await new Promise(resolve => setTimeout(resolve, intervalMs));
}
throw new Error('Timed out waiting for condition');
}

async function profileEndpoint(baseUrl, path) {
const response = await fetch(`${baseUrl}${path}`);
const buffer = await response.arrayBuffer();
return {
status: response.status,
bytes: buffer.byteLength,
};
}

async function run() {
const ingestRequests = [];

const app = express();
app.post(
'/ingest',
express.raw({
type: () => true,
limit: '10mb',
}),
(req, res) => {
ingestRequests.push({
bodyLength: Buffer.isBuffer(req.body) ? req.body.length : 0,
contentType: req.headers['content-type'],
query: req.query,
});
res.status(204).end();
}
);
app.use(Pyroscope.expressMiddleware());
const server = await listen(app);

try {
const address = server.address();
assert(address && typeof address === 'object', 'Failed to resolve listen address');

const baseUrl = `http://127.0.0.1:${address.port}`;
// Validate the flow where continuous profiling is already active and
// pull endpoints are invoked from middleware.
Pyroscope.init({
appName: 'pyroscope-nodejs-bun-smoke',
serverAddress: baseUrl,
flushIntervalMs: 250,
tags: {
runtime: 'bun',
suite: 'smoke',
},
});
Pyroscope.start();

const [heap, wall] = await Promise.all([
profileEndpoint(baseUrl, '/debug/pprof/heap'),
profileEndpoint(baseUrl, '/debug/pprof/profile?seconds=1'),
]);

assert(heap.status === 200, `Heap endpoint returned ${heap.status}`);
assert(heap.bytes > 0, 'Heap endpoint returned an empty profile payload');
assert(wall.status === 200, `Wall endpoint returned ${wall.status}`);
assert(wall.bytes > 0, 'Wall endpoint returned an empty profile payload');
await waitFor(() => ingestRequests.length > 0, 5000);
const ingest = ingestRequests[0];
assert(ingest.bodyLength > 0, 'Ingest endpoint received an empty body');
assert(
typeof ingest.contentType === 'string' &&
ingest.contentType.startsWith('multipart/form-data'),
'Ingest endpoint did not receive multipart form data'
);
assert(typeof ingest.query.from === 'string', 'Ingest query missing from');
assert(typeof ingest.query.until === 'string', 'Ingest query missing until');
assert(typeof ingest.query.name === 'string', 'Ingest query missing name');
assert(typeof ingest.query.spyName === 'string', 'Ingest query missing spyName');

console.log(
JSON.stringify({
ok: true,
heapBytes: heap.bytes,
wallBytes: wall.bytes,
ingestRequests: ingestRequests.length,
})
);
} finally {
await close(server);
await Pyroscope.stop();
}
}

await run();
Loading