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
190 changes: 189 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion packages/browser/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@harperfast/prerender-browser",
"version": "1.12.0",
"version": "1.13.0",
"type": "module",
"description": "Headless-browser render library for Harper Prerender: claims render jobs from the @harperfast/prerender queue, renders pages in headless Chrome (Puppeteer), and posts the HTML back. Embedded by a render service and configured entirely via startWorker() options.",
"keywords": [
Expand Down Expand Up @@ -44,6 +44,10 @@
"prepublishOnly": "npm run build"
},
"dependencies": {
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/exporter-metrics-otlp-proto": "^0.221.0",
"@opentelemetry/resources": "^2.10.0",
"@opentelemetry/sdk-metrics": "^2.10.0",
"mqtt": "^5.10.4",
"pino": "^9.9.0",
"puppeteer": "^24.7.2",
Expand Down
69 changes: 69 additions & 0 deletions packages/browser/src/Worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { noop } from './util/noop.js';
import { getResourceCache } from './ResourceCache.js';
import { settings } from './settings.js';
import { CpuSampler } from './util/cpu.js';
import type { MetricsRecorder } from './metrics.js';

export type Renderer = (page: Page, job: RenderJob) => Promise<string | undefined>;

Expand All @@ -29,6 +30,12 @@ type RenderWorkerConfig = {
rps?: number;

browserLaunchOptions?: LaunchOptions;

/**
* Optional metrics recorder. When present, each stats window is folded into it (see
* `logStats`) and it is flushed on shutdown. Absent unless the embedder enabled OTLP metrics.
*/
metrics?: MetricsRecorder;
};

export default class RenderWorker {
Expand Down Expand Up @@ -57,6 +64,9 @@ export default class RenderWorker {

rps = 10;

// Optional OTLP metrics recorder; null unless the embedder enabled metrics.
private metrics: MetricsRecorder | null = null;

inflight: Set<Promise<void>> = new Set();

lastRenderStartTime = Date.now();
Expand Down Expand Up @@ -102,6 +112,7 @@ export default class RenderWorker {
this.CONCURRENCY = config.maxConcurrency ?? 5;
this.BROWSER_MAX_TOTAL_PAGES = config.browserExpirationThreshold ?? 5000;
this.renderFn = config.renderer;
this.metrics = config.metrics ?? null;

this.browserCleanupInterval = setInterval(() => {
this.closeRetiredBrowsers();
Expand Down Expand Up @@ -284,6 +295,40 @@ export default class RenderWorker {
rssMb: Math.round(mem.rss / 1024 / 1024),
resourceCache: cacheStats,
});

// Fold the same window into OTLP metrics (no-op unless the embedder enabled them). `s`
// still holds the raw per-render sample arrays, so histograms get true observations —
// not the already-summarized percentiles above.
this.metrics?.record(
{
completed: s.completed,
succeeded: s.succeeded,
emptyContent: s.emptyContent,
fromSitemap: s.fromSitemap,
failures: s.failures,
expiredSkipped: s.expiredSkipped,
concurrencyBlocked: s.concurrencyBlocked,
rpsDelayed: s.rpsDelayed,
resultPostFailures: s.resultPostFailures,
browserLaunches: s.browserLaunches,
browserRetirements: s.browserRetirements,
renderTimes: s.renderTimes,
navTtfb: s.navTtfb,
navTotal: s.navTotal,
settle: s.settle,
postProcess: s.postProcess,
},
{
inflight: this.inflight.size,
concurrency: this.CONCURRENCY,
retiredBrowsers: this.retiredBrowsers.size,
rssBytes: mem.rss,
workerCores: cpu.workerCores,
nodeCores: cpu.nodeCores,
browserCores: cpu.browserCores,
cacheHitRate: cacheStats ? cacheStats.hitRate : null,
}
);
}

/**
Expand Down Expand Up @@ -318,7 +363,31 @@ export default class RenderWorker {
this.browserCleanupInterval = null;
}

// Emit one final stats window before tearing down. The interval timer is now cleared, so the
// up-to-one-window of counts/timings accumulated since the last tick would otherwise be lost
// from both the log line and (more importantly) the metrics flush below — logStats() folds it
// into the recorder via record(), while this.metrics is still set. Guarded so a failure here
// (e.g. mid-uncaughtException) can't block the browser cleanup that follows.
try {
this.logStats();
} catch (err) {
logger.warn({ err }, 'failed to log final stats during destroy');
}

const closing: Promise<void>[] = [];
// Flush the buffered metrics (incl. the final window just recorded above) before the loop
// dies (shutdown() is internally guarded — never throws). Cap the wait so a down collector's
// flush retries can't delay container exit — losing that last export to a dead collector is an
// acceptable trade. AbortController cancels the cap timer once the flush wins so it doesn't
// linger on the event loop (mirrors the drain above).
if (this.metrics) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

During graceful shutdown or uncaught exceptions, destroy() is called to clean up resources. However, any metrics accumulated since the last 60-second logStats() interval are currently lost because logStats() is not called before shutting down the metrics provider. Calling this.logStats() at the start of destroy() ensures that the final window's metrics are captured, logged, and successfully exported via OpenTelemetry. Wrapping it in a try-catch block ensures that any unexpected errors during stats collection (e.g., during an uncaught exception) do not block the critical browser cleanup process.

Suggested change
if (this.metrics) {
try {
this.logStats();
} catch (err) {
logger.warn({ err }, 'failed to log final stats during destroy');
}
if (this.metrics) {

const metrics = this.metrics;
this.metrics = null;
const ac = new AbortController();
const flush = metrics.shutdown().finally(() => ac.abort());
const cap = setTimeout(2000, undefined, { signal: ac.signal }).catch(() => {});
closing.push(Promise.race([flush, cap]));
}
if (this.browser) {
closing.push(this.browser.close().catch(noop));
}
Expand Down
17 changes: 17 additions & 0 deletions packages/browser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { applySettings, settings, defaultLaunchOptions } from './settings.js';
import type { BrowserOptions } from './settings.js';
import { initResourceCache } from './ResourceCache.js';
import { ErrorHandler } from './errorHandler.js';
import type { MetricsRecorder } from './metrics.js';

export type StartWorkerOptions = BrowserOptions & {
/** Renderer to use instead of the built-in default. */
Expand Down Expand Up @@ -54,9 +55,24 @@ export async function startWorker(options: StartWorkerOptions): Promise<RenderWo
...settings,
harper: { ...settings.harper, pass: settings.harper.pass ? 'REDACTED' : '' },
bypass: { ...settings.bypass, token: settings.bypass.token ? 'REDACTED' : '' },
// headers may carry an auth token for the collector — never log their values.
metrics: { ...settings.metrics, headers: Object.keys(settings.metrics.headers) },
},
});

// Optional OTLP metrics: construct only when enabled AND an endpoint is set — that dynamic
// import is the only thing that loads the OpenTelemetry SDK, so a disabled worker pays nothing.
// A metrics-init failure must never stop the worker from rendering.
let metrics: MetricsRecorder | undefined;
if (settings.metrics.enabled && settings.metrics.otlpEndpoint) {
try {
const { createMetrics } = await import('./metrics.js');
metrics = await createMetrics(settings.metrics, { workerId: settings.harper.workerId });
} catch (err) {
logger.error({ err }, 'failed to start metrics export — continuing without metrics');
}
}

// Block job intake until the cache has scanned disk and built its in-memory index.
await initResourceCache(settings.resourceCache);

Expand All @@ -66,6 +82,7 @@ export async function startWorker(options: StartWorkerOptions): Promise<RenderWo
rps: settings.rps,
browserLaunchOptions: settings.browserLaunchOptions ?? defaultLaunchOptions(),
renderer,
metrics,
});

// Install signal handlers AFTER the worker exists so SIGTERM/SIGINT can drain it
Expand Down
Loading