Skip to content

Commit 9204b32

Browse files
authored
feat(profiling): add @pyroscope/nodejs for continuous CPU/wall profiling (#131)
Initializes Pyroscope SDK before server start for full V8 call-stack flamegraphs. Gracefully skips when PYROSCOPE_SERVER_ADDRESS is unset. ~2-3% CPU overhead via V8's built-in sampling profiler at 100Hz.
1 parent e4723c0 commit 9204b32

File tree

4 files changed

+164
-13
lines changed

4 files changed

+164
-13
lines changed

package-lock.json

Lines changed: 126 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
"@divmode/rrweb-plugin-console-record": "^0.0.40",
6363
"@hapi/bourne": "^3.0.0",
6464
"@hapi/hoek": "^11.0.4",
65+
"@pyroscope/nodejs": "^0.4.10",
6566
"better-sqlite3": "^11.10.0",
6667
"debug": "^4.4.1",
6768
"del": "^8.0.1",

src/index.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,25 @@
1+
import Pyroscope from '@pyroscope/nodejs';
12
import { Browserless } from '@browserless.io/browserless';
23
import { Effect } from 'effect';
34

5+
// ── Continuous profiling (Pyroscope) ─────────────────────────────────
6+
// Must init before any other code to capture full startup profile.
7+
// Gracefully skips when PYROSCOPE_SERVER_ADDRESS is unset (local dev).
8+
if (process.env.PYROSCOPE_SERVER_ADDRESS) {
9+
Pyroscope.init({
10+
serverAddress: process.env.PYROSCOPE_SERVER_ADDRESS,
11+
appName: process.env.OTEL_SERVICE_NAME ?? 'browserless',
12+
basicAuthUser: process.env.PYROSCOPE_BASIC_AUTH_USER ?? '',
13+
basicAuthPassword: process.env.PYROSCOPE_BASIC_AUTH_PASSWORD ?? '',
14+
wall: { collectCpuTime: true },
15+
tags: {
16+
env: process.env.OTEL_DEPLOYMENT_ENVIRONMENT ?? 'production',
17+
server: 'flatcar',
18+
},
19+
});
20+
Pyroscope.start();
21+
}
22+
423
// ── Fail-fast env validation ─────────────────────────────────────────
524
// REQUIRED env vars must be present BEFORE the server accepts connections.
625
// Without this, a stale `node --watch` process (started without proper env)

src/session/cloudflare-solver.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Cause, Effect, Exit, FiberMap, Layer, ManagedRuntime, Scope, type Tracer } from 'effect';
1+
import { Cause, Effect, Exit, FiberMap, Layer, ManagedRuntime, Scope, Semaphore, type Tracer } from 'effect';
22
import { CdpSessionId } from '../shared/cloudflare-detection.js';
33
import type { TargetId, CloudflareConfig } from '../shared/cloudflare-detection.js';
44
import { CloudflareDetector } from './cf/cloudflare-detector.js';
@@ -150,13 +150,20 @@ export class CloudflareSolver {
150150

151151
const proxyOrDirect: SendCommand = (...args) => (self.sendViaProxy || sendCommand)(...args);
152152

153-
const cdpSenderLayer = Layer.succeed(CdpSender, CdpSender.of({
154-
send: (method, params, sessionId, timeoutMs) =>
155-
liftSend(sendCommand, method, params, sessionId, timeoutMs),
156-
sendViaProxy: (method, params, sessionId, timeoutMs) =>
157-
liftSend(proxyOrDirect, method, params, sessionId, timeoutMs),
158-
sendViaBrowser: (method, params, sessionId, timeoutMs) =>
159-
liftSend(proxyOrDirect, method, params, sessionId, timeoutMs),
153+
// Semaphore limits concurrent CDP commands to Chrome — prevents backpressure
154+
// when multiple tabs have active detection/solve loops firing simultaneously.
155+
const CDP_CONCURRENCY = 3;
156+
const cdpSenderLayer = Layer.effect(CdpSender, Effect.gen(function*() {
157+
const sem = yield* Semaphore.make(CDP_CONCURRENCY);
158+
const throttle = <A, E>(effect: Effect.Effect<A, E>) => sem.withPermits(1)(effect);
159+
return CdpSender.of({
160+
send: (method, params, sessionId, timeoutMs) =>
161+
throttle(liftSend(sendCommand, method, params, sessionId, timeoutMs)),
162+
sendViaProxy: (method, params, sessionId, timeoutMs) =>
163+
throttle(liftSend(proxyOrDirect, method, params, sessionId, timeoutMs)),
164+
sendViaBrowser: (method, params, sessionId, timeoutMs) =>
165+
throttle(liftSend(proxyOrDirect, method, params, sessionId, timeoutMs)),
166+
});
160167
}));
161168

162169
const solverEventsLayer = Layer.succeed(SolverEvents, SolverEvents.of({
@@ -196,8 +203,8 @@ export class CloudflareSolver {
196203
}));
197204

198205
// SolveDispatcher — routes solve attempts through the Effect solver.
199-
// Per-solve isolated WS: each solve gets its own WebSocket to Chrome,
200-
// Each solve gets its own isolated WS connection, so no concurrency limit needed.
206+
// Per-solve isolated WS: each solve gets its own WebSocket to Chrome.
207+
// Browser-level sends (originalSender) inherit the Semaphore from cdpSenderLayer.
201208
const solveDispatcherLayer = Layer.effect(SolveDispatcher, Effect.gen(function*() {
202209
const solverEvents = yield* SolverEvents;
203210
const solveDeps = yield* SolveDeps;
@@ -411,7 +418,7 @@ export class CloudflareSolver {
411418
}
412419

413420
// FiberMap.run auto-interrupts existing fiber for same key.
414-
// The detection effect is wrapped in catchAllCause to prevent silent fiber
421+
// The detection effect is wrapped in catchCause to prevent silent fiber
415422
// death — without this, defects (NPE in emitClientEvent, etc.) kill the fiber
416423
// and pydoll never receives cf.solved/cf.failed (the "events=1" failure mode).
417424
const guarded = this.detector.detectTurnstileWidgetEffect(targetId, cdpSessionId).pipe(

0 commit comments

Comments
 (0)