Skip to main content

๐Ÿ“ธ Build Thumbnails

๐Ÿ“– Overviewโ€‹

The Build Thumbnails job generates desktop and mobile preview images for funnel pages using headless Chrome (Puppeteer). It runs every minute, identifies steps that need thumbnails (new, changed, or stale), captures screenshots at two viewport sizes, uploads them to Wasabi S3, and updates the database with thumbnail metadata. The system supports stale job recovery (2-hour threshold) and handles blank pages gracefully.

Complete Flow:

  1. Cron Initialization: queue-manager/crons/funnels/buildThumbnails.js
  2. Service Processing: queue-manager/services/funnels/buildThumbnails.js
  3. Queue Definition: queue-manager/queues/funnels/buildThumbnails.js

Execution Pattern: High-frequency polling (every minute) with batch processing

Queue Name: build_thumbnails

Environment Flag: QM_FUNNELS_BUILD_THUMBNAILS=true (in index.js)

๐Ÿ”„ Complete Processing Flowโ€‹

sequenceDiagram
participant CRON as Cron Schedule<br/>(every 1 min)
participant SERVICE as Build Service
participant DB as Funnel Steps DB
participant QUEUE as Thumbnail Queue
participant PUPPETEER as Puppeteer<br/>Browser
participant WASABI as Wasabi S3

CRON->>SERVICE: buildThumbnails()

loop While steps need processing
SERVICE->>DB: Find steps where:<br/>- step_changed = true OR<br/>- thumbnails missing OR<br/>- processing_queued & >2hrs old
DB-->>SERVICE: Steps needing thumbnails

alt No steps found
SERVICE->>CRON: Complete (log: no steps)
else Steps found
loop Each step
SERVICE->>DB: Set flags:<br/>processing_queued=true<br/>thumbnail_process_started_at=now
SERVICE->>QUEUE: Add job: {pageUrl, step_id, type}
end
end
end

QUEUE->>PUPPETEER: Launch browser (if not running)
QUEUE->>PUPPETEER: Navigate to pageUrl<br/>(30s timeout race)

alt Page blank
QUEUE->>QUEUE: Skip (log warning)
else Page has content
QUEUE->>PUPPETEER: Set desktop viewport<br/>(1600x1000)
QUEUE->>PUPPETEER: Capture JPEG (60% quality)

QUEUE->>PUPPETEER: Set mobile viewport<br/>(411x900)
QUEUE->>PUPPETEER: Capture JPEG (60% quality)

loop Each screenshot
QUEUE->>WASABI: Upload to S3<br/>(key: step_id/desktop|mobile.jpg)
QUEUE->>QUEUE: Delete temp file
end

QUEUE->>DB: Update step:<br/>thumbnails={desktop, mobile}<br/>Clear processing flags
end

๐Ÿ“ Source Filesโ€‹

1. Cron Initializationโ€‹

File: queue-manager/crons/funnels/buildThumbnails.js

Purpose: Schedule thumbnail generation every minute

Cron Pattern: * * * * * (every minute)

Initialization with Job Tracking:

const buildThumbnails = require('../../services/funnels/buildThumbnails');
const cron = require('node-cron');
const logger = require('../../utilities/logger');

let inProgress = false;
let cronJobCount = 0;

exports.start = async () => {
try {
logger.log({
initiator: 'QM/funnels/build-thumbnails',
message: 'Starting thumbnail build cron job scheduler',
});

cron.schedule('* * * * *', async () => {
cronJobCount++;
const jobId = `job-${cronJobCount}-${Date.now()}`;

if (inProgress) {
logger.log({
initiator: 'QM/funnels/build-thumbnails',
message: `Skipping job ${jobId} - previous job still in progress`,
additional_data: { jobId },
});
return;
}

try {
inProgress = true;
await buildThumbnails();
} catch (error) {
logger.error({
initiator: 'QM/funnels/build-thumbnails',
message: `Error in thumbnail build process (${jobId})`,
error: error,
additional_data: { jobId },
});
} finally {
inProgress = false;
}
});
} catch (err) {
logger.error({
initiator: 'QM/funnels/build-thumbnails',
message: 'Failed to start thumbnail build cron job',
error: err,
});
}
};

In-Progress Lock: Prevents overlapping executions with enhanced logging.

Job Tracking: Each cron execution gets unique jobId for debugging.

2. Service Processing (THE CONTINUOUS LOOP)โ€‹

File: queue-manager/services/funnels/buildThumbnails.js

Purpose: Find steps needing thumbnails and queue them continuously

Key Features:

  • Continuous loop until no more steps found
  • Handles new steps, changed steps, and stale processing
  • 2-hour stale threshold for recovery
  • Batch job queuing with progress logging

Main Service Function:

const Queue = require('../../queues/funnels/buildThumbnails');
const logger = require('../../utilities/logger');
const FunnelStep = require('../../models/funnel.step');

module.exports = async () => {
try {
const queue = await Queue.start();
let totalProcessedCount = 0;
let hasMoreSteps = true;

while (hasMoreSteps) {
const query = {
$or: [
{
$and: [
{
$or: [
{ step_changed: true },
{ thumbnails: null },
{ thumbnails: { $exists: false } },
{ 'thumbnails.desktop': null },
{ 'thumbnails.desktop': { $exists: false } },
],
},
{ processing_queued: { $ne: true } },
],
},
{
$and: [
{ processing_queued: true },
{
$or: [
{ thumbnail_process_started_at: { $exists: false } },
{
thumbnail_process_started_at: {
$lte: new Date(Date.now() - 2 * 60 * 60 * 1000),
},
},
],
},
],
},
],
};

// Get the next steps that need processing
const steps = await FunnelStep.find(query).lean().exec();

if (!steps.length) {
hasMoreSteps = false;
if (totalProcessedCount === 0) {
logger.log({
initiator: 'QM/funnels/build-thumbnails',
message: 'No funnel steps found to build thumbnails.',
});
}
break;
}

logger.log({
initiator: 'QM/funnels/build-thumbnails',
message: `Found ${steps.length} steps requiring thumbnail generation`,
});

// Add jobs to queue for each step
for (const step of steps) {
const queueData = {
funnel_id: step.funnel_id,
step_id: step._id,
type: 'funnel',
pageUrl: `https://preview.urlme.app/${step.funnel_id}${step.base_path}`,
filePrefix: step._id,
};
await queue.add(queueData, {
attempts: 1,
removeOnComplete: true,
removeOnFail: true,
timeout: 150000,
backoff: {
type: 'exponential',
delay: 1000,
},
});
await FunnelStep.updateOne(
{ _id: step._id },
{ $set: { processing_queued: true, thumbnail_process_started_at: new Date() } },
);
}

totalProcessedCount += steps.length;

logger.log({
initiator: 'QM/funnels/build-thumbnails',
message: `Added ${steps.length} thumbnail generation jobs to queue`,
});
}

if (totalProcessedCount > 0) {
logger.log({
initiator: 'QM/funnels/build-thumbnails',
message: `Completed processing. Total ${totalProcessedCount} thumbnail generation jobs added to queue`,
});
}
} catch (err) {
logger.error({
initiator: 'QM/funnels/build-thumbnails',
message: `Error occurred while scheduling build thumbnails.`,
error: err,
});
}
};

Query Breakdown:

Condition 1: Steps needing thumbnails (not already queued)

  • step_changed = true - content modified
  • thumbnails = null or missing - never generated
  • thumbnails.desktop = null or missing - incomplete generation
  • AND processing_queued โ‰  true - not already queued

Condition 2: Stale processing recovery

  • processing_queued = true - job was queued
  • AND thumbnail_process_started_at missing or >2 hours old - stuck/stale job

3. Queue Processing (THE PUPPETEER LOGIC)โ€‹

File: queue-manager/queues/funnels/buildThumbnails.js

Purpose: Generate thumbnails using Puppeteer and upload to Wasabi S3

Key Functions:

  • Browser management (singleton pattern)
  • Screenshot generation at multiple viewports
  • Wasabi S3 upload
  • Temporary file cleanup
  • Blank page detection

Helper Functions (Simplified - full code ~500 lines):

const FunnelStep = require('../../models/funnel.step');
const QueueWrapper = require('../../common/queue-wrapper');
const puppeteer = require('puppeteer');
const fs = require('fs');
const path = require('path');
const Upload = new (require('../../utils/wasabi'))();
const uuid = require('uuid');
const logger = require('../../utilities/logger');

const getDimensions = type => {
switch (type) {
case 'desktop':
return { width: 1600, height: 1000 };
case 'mobile':
return { width: 411, height: 900 };
}
};

const screenshotsGenerator = async (browser, url, types) => {
let page;
try {
page = await browser.newPage();
page.setDefaultTimeout(60000);
page.setDefaultNavigationTimeout(60000);

// Race condition: wait max 30s for page load
await Promise.race([
page.goto(url, { waitUntil: 'networkidle2', timeout: 0 }),
new Promise(resolve => setTimeout(resolve, 30000)),
]);

// Check if page is blank
const isPageBlank = await page.evaluate(() => {
const body = document.querySelector('body');
return body.innerHTML.trim() === '';
});

if (isPageBlank) {
logger.warn({
initiator: 'QM/funnels/build-thumbnails',
message: 'The page is blank, skipping screenshot',
additional_data: { url, types },
});
return null;
}

// Prevent scrollbars in screenshots
await page.evaluate(() => {
document.body.style.overflow = 'hidden';
});

const screenshots = [];

// Generate screenshots sequentially to save resources
for (const type of types) {
await page.setViewport(getDimensions(type));
await new Promise(resolve => setTimeout(resolve, 2000)); // Viewport settle time
const dirPath = getFileName(uuid.v4());
const { fileName, filePath } = dirPath;
await page.screenshot({
path: filePath,
type: 'jpeg',
quality: 60,
fullPage: false,
});

screenshots.push({
fileName,
filePath,
});
}

return screenshots;
} catch (error) {
logger.error({
initiator: 'QM/funnels/build-thumbnails',
message: `Failed to generate screenshot for ${types.join(', ')}`,
error: error,
additional_data: { url },
});
throw error;
} finally {
if (page) {
await page.close();
}
}
};

let browser;
const getThumbnails = async url => {
try {
if (!browser) {
browser = await puppeteer.launch({
headless: 'new',
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
'--disable-software-rasterizer',
'--disable-web-security',
'--disable-features=VizDisplayCompositor,TranslateUI,BlinkGenPropertyTrees',
'--disable-background-timer-throttling',
'--disable-backgrounding-occluded-windows',
'--disable-renderer-backgrounding',
'--disable-extensions',
'--disable-plugins',
'--disable-default-apps',
'--disable-background-networking',
'--disable-sync',
'--disable-translate',
'--disable-ipc-flooding-protection',
'--memory-pressure-off',
'--no-zygote',
'--disable-dev-tools',
'--disable-background-mode',
'--no-first-run',
'--disable-accelerated-2d-canvas',
'--noerrdialogs',
],
});
}

const [desktop, mobile] = await screenshotsGenerator(browser, url, ['desktop', 'mobile']);

return {
desktop,
mobile,
};
} catch (error) {
logger.error({
initiator: 'QM/funnels/build-thumbnails',
message: 'Failed to generate thumbnails',
error: error,
additional_data: { url },
});
throw error;
}
};

const getFileName = name => {
const dir = path.join(require('os').tmpdir(), 'funnels/thumbnails/');
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const fileName = name.replace(/\s/g, '').replace(/\//g, '') + '.jpg';
const filePath = path.join(dir, fileName);
return {
fileName,
filePath,
};
};

const uploadFile = (fileName, name) => {
const fileContent = fs.readFileSync(fileName);
return Upload.upload([
{ contentType: 'image/jpeg', filename: `${name || uuid.v4()}.jpg`, content: fileContent },
]);
};

const generateThumbnailSet = async job => {
const data = job.data;
if (!data) throw new Error('No data provided');

const pageUrl = data.pageUrl;
const type = data.type;
let thumbnails = null;

try {
if (!pageUrl) throw new Error('No page url provided');

thumbnails = await getThumbnails(pageUrl);
if (!thumbnails) {
logger.error({
initiator: 'QM/funnels/build-thumbnails',
message: `Error while generating thumbnails. No thumbnails found.`,
additional_data: { pageUrl },
});
return null;
}

logger.log({
initiator: 'QM/funnels/build-thumbnails',
message: `Generated thumbnails`,
additional_data: { pageUrl },
});

let files = {};
for (let thumbnail in thumbnails) {
if (!thumbnails[thumbnail]) continue; // Skip null (blank page)

try {
files[thumbnail] = await uploadFile(
thumbnails[thumbnail].filePath,
data?.filePrefix ? `${data?.filePrefix}/${thumbnail}` : null,
);
} catch (uploadError) {
logger.error({
initiator: 'QM/funnels/build-thumbnails',
message: `Error uploading thumbnail`,
error: uploadError,
additional_data: { pageUrl, thumbnail },
});
throw uploadError;
}
}

logger.log({
initiator: 'QM/funnels/build-thumbnails',
message: `Uploaded thumbnails`,
additional_data: { pageUrl },
});

for (let file in files) {
files[file] = files[file][0];
}

for (let thumb in thumbnails) {
files[thumb].size = fs.statSync(thumbnails[thumb].filePath).size;
fs.unlinkSync(thumbnails[thumb].filePath);
thumbnails[thumb].cleaned = true;
}

if (Object.keys(files).length > 0) {
await FunnelStep.updateOne(
{ _id: data.step_id },
{
$set: {
thumbnails: files,
thumbnails_updated_at: Date.now(),
},
$unset: {
step_changed: '',
processing_queued: '',
thumbnail_build_in_progress: '',
thumbnail_process_started_at: '',
},
},
);
logger.log({
initiator: 'QM/funnels/build-thumbnails',
message: 'Funnel thumbnail processed.',
additional_data: {
job: job.id,
job_data: data,
files,
},
});
} else {
throw new Error('No thumbnails were successfully generated');
}

return files;
} catch (error) {
logger.error({
initiator: 'QM/funnels/build-thumbnails',
message: `Error in thumbnail generation process`,
error,
additional_data: { pageUrl, type },
});
throw error;
} finally {
// Clean up temp files
if (thumbnails) {
for (let thumb in thumbnails) {
if (
thumbnails[thumb].filePath &&
fs.existsSync(thumbnails[thumb].filePath) &&
!thumbnails[thumb].cleaned
) {
try {
fs.unlinkSync(thumbnails[thumb].filePath);
} catch (cleanupErr) {
logger.warn({
initiator: 'QM/funnels/build-thumbnails',
message: `Error during cleanup: ${cleanupErr.message}`,
});
}
}
}
}
}
};

const processCb = async (job, done) => {
try {
await generateThumbnailSet(job);
} finally {
done();
}
};

const failedCb = async (job, err) => {
await FunnelStep.updateMany(
{ _id: job.data.step_id },
{
$unset: {
processing_queued: '',
thumbnail_build_in_progress: '',
thumbnail_process_started_at: '',
},
},
);
logger.error({
initiator: 'QM/funnels/build-thumbnails',
error: err,
additional_data: { job: job.id, job_data: job.data },
});
};

const completedCb = async job => {
logger.log({
initiator: 'QM/funnels/build-thumbnails',
message: 'Thumbnail generation completed.',
additional_data: { job: job.id, job_data: job.data },
});
};

let queue;

exports.start = async () => {
try {
const queueOptions = {
stalledInterval: 30000,
maxStalledCount: 0,
lockDuration: 120000,
lockRenewTime: 60000,
};

if (!queue) {
queue = QueueWrapper(`build_thumbnails`, 'global', {
processCb,
failedCb,
completedCb,
settings: queueOptions,
concurrency: 1,
});
}
return Promise.resolve(queue);
} catch (err) {
logger.error({ message: 'Error while initializing data queue', error: err });
}
};

module.exports.getThumbnails = getThumbnails;

๐Ÿ—„๏ธ Collections Usedโ€‹

funnel.stepโ€‹

  • Operations: Find, Update
  • Model: shared/models/funnel.step.js
  • Usage Context: Track thumbnail generation status

Key Fields:

  • _id: Step identifier
  • funnel_id: Parent funnel reference
  • base_path: URL path for page (e.g., /landing)
  • step_changed: Boolean - content modified, needs new thumbnail
  • thumbnails: Object - stores thumbnail metadata
    • desktop: Object - desktop thumbnail info (key, size, url)
    • mobile: Object - mobile thumbnail info (key, size, url)
  • thumbnails_updated_at: Timestamp - last thumbnail generation
  • processing_queued: Boolean - job queued flag
  • thumbnail_build_in_progress: Boolean - generation in progress
  • thumbnail_process_started_at: Date - when processing started (for stale detection)

๐Ÿ”ง Job Configurationโ€‹

Cron Scheduleโ€‹

'* * * * *'; // Every minute

Frequency: 60 times per hour

Rationale: High frequency ensures quick thumbnail generation for newly published pages.

Stale Job Thresholdโ€‹

const TWO_HOURS = 2 * 60 * 60 * 1000;
const staleThreshold = new Date(Date.now() - TWO_HOURS);

Threshold: 2 hours

Production Behavior: Prod may use different threshold (verify with environment variable if implemented).

Queue Settingsโ€‹

const queueOptions = {
stalledInterval: 30000, // Check for stalled jobs every 30 seconds
maxStalledCount: 0, // Never mark jobs as stalled (rely on service stale detection)
lockDuration: 120000, // 2-minute lock (max processing time)
lockRenewTime: 60000, // Renew lock every 60 seconds
};

QueueWrapper(`build_thumbnails`, 'global', {
processCb,
failedCb,
completedCb,
settings: queueOptions,
concurrency: 1, // Process 1 thumbnail at a time
});

Job Optionsโ€‹

{
attempts: 1, // No retries (service will re-queue stale jobs)
removeOnComplete: true, // Clean up successful jobs immediately
removeOnFail: true, // Clean up failed jobs immediately
timeout: 150000, // 2.5-minute timeout (150 seconds)
backoff: {
type: 'exponential',
delay: 1000, // Start with 1-second delay (not used with attempts=1)
},
}

Viewport Configurationsโ€‹

Desktop:

{ width: 1600, height: 1000 }

Mobile:

{ width: 411, height: 900 }

Image Settingsโ€‹

  • Format: JPEG
  • Quality: 60%
  • Full Page: false (viewport height only)

๐Ÿ“‹ Processing Logic - Detailed Flowโ€‹

1. Service Query Logicโ€‹

Find Steps Needing Thumbnails:

Scenario A: Never generated or changed (not queued)

{
$and: [
{
$or: [
{ step_changed: true }, // Content modified
{ thumbnails: null }, // Never generated
{ thumbnails: { $exists: false } }, // Field missing
{ 'thumbnails.desktop': null }, // Incomplete
{ 'thumbnails.desktop': { $exists: false } }, // Incomplete
],
},
{ processing_queued: { $ne: true } }, // Not already queued
];
}

Scenario B: Stale processing (>2 hours)

{
$and: [
{ processing_queued: true },
{
$or: [
{ thumbnail_process_started_at: { $exists: false } }, // Missing timestamp
{
thumbnail_process_started_at: {
$lte: new Date(Date.now() - 2 * 60 * 60 * 1000), // >2 hours ago
},
},
],
},
];
}

2. Continuous Processing Loopโ€‹

While Loop Pattern:

while (hasMoreSteps) {
const steps = await FunnelStep.find(query);

if (!steps.length) {
hasMoreSteps = false;
break;
}

// Queue all found steps
for (const step of steps) {
await queue.add(queueData, options);
await FunnelStep.updateOne(
{ _id: step._id },
{ processing_queued: true, thumbnail_process_started_at: new Date() },
);
}

totalProcessedCount += steps.length;
}

Rationale: Ensures all pending steps are queued in single cron execution.

3. Puppeteer Browser Managementโ€‹

Singleton Pattern:

let browser;  // Module-level variable

const getThumbnails = async url => {
if (!browser) {
browser = await puppeteer.launch({ ... });
}
// Reuse existing browser
};

Benefits:

  • Faster subsequent screenshot generation
  • Lower memory usage
  • Reduced process overhead

Caveat: Browser persists across jobs (potential memory leak if not managed).

4. Screenshot Generationโ€‹

Process:

  1. Open New Page: await browser.newPage()
  2. Set Timeouts: 60-second default timeout
  3. Navigate: Race between networkidle2 and 30-second timeout
  4. Blank Check: Evaluate document.body.innerHTML.trim() === ''
  5. Hide Scrollbars: Set overflow: hidden
  6. For Each Viewport:
    • Set viewport dimensions
    • Wait 2 seconds (settle time)
    • Generate unique filename
    • Capture JPEG screenshot
  7. Close Page: await page.close()

Sequential Processing: One viewport at a time to conserve resources.

5. Wasabi Uploadโ€‹

Upload Pattern:

const uploadFile = (fileName, name) => {
const fileContent = fs.readFileSync(fileName);
return Upload.upload([
{
contentType: 'image/jpeg',
filename: `${name || uuid.v4()}.jpg`,
content: fileContent,
},
]);
};

// Usage with step-based prefix
files[thumbnail] = await uploadFile(
thumbnails[thumbnail].filePath,
`${step._id}/${thumbnail}`, // e.g., "507f1f77bcf86cd799439011/desktop"
);

S3 Key Structure: {step_id}/{viewport}.jpg

  • Example: 507f1f77bcf86cd799439011/desktop.jpg
  • Example: 507f1f77bcf86cd799439011/mobile.jpg

6. Temporary File Cleanupโ€‹

Lifecycle:

  1. Create temp file: os.tmpdir()/funnels/thumbnails/{uuid}.jpg
  2. Upload to S3
  3. Mark as cleaned: thumbnails[thumb].cleaned = true
  4. Delete temp file: fs.unlinkSync(filePath)

Finally Block: Ensures cleanup even on error

finally {
if (thumbnails) {
for (let thumb in thumbnails) {
if (exists && !cleaned) {
fs.unlinkSync(filePath);
}
}
}
}

7. Database Updateโ€‹

Success:

await FunnelStep.updateOne(
{ _id: step_id },
{
$set: {
thumbnails: files,
thumbnails_updated_at: Date.now(),
},
$unset: {
step_changed: '',
processing_queued: '',
thumbnail_build_in_progress: '',
thumbnail_process_started_at: '',
},
},
);

Failure (in failedCb):

await FunnelStep.updateMany(
{ _id: step_id },
{
$unset: {
processing_queued: '',
thumbnail_build_in_progress: '',
thumbnail_process_started_at: '',
},
},
);

๐Ÿšจ Error Handlingโ€‹

Common Error Scenariosโ€‹

Page Load Timeoutโ€‹

Scenario: Page doesn't reach networkidle2 within 30 seconds

Handling: Take screenshot anyway (race condition resolves)

Impact: May capture partially loaded page

Blank Page Detectionโ€‹

Scenario: Page renders with empty body

Handling:

if (isPageBlank) {
logger.warn({ message: 'The page is blank, skipping screenshot' });
return null;
}

Impact: No thumbnail generated, processing flags cleared, retry on next run

Puppeteer Launch Failureโ€‹

Scenario: Browser fails to launch (resource constraints, missing dependencies)

Handling: Error thrown, job fails, flags cleared

Impact: All queued jobs fail until browser issue resolved

Upload Failureโ€‹

Scenario: Wasabi S3 upload fails (network, permissions, quota)

Handling: Error thrown, temp files cleaned in finally block, job fails

Impact: Flags cleared, retry on next service run

File System Errorsโ€‹

Scenario: Temp directory full, permissions issues

Handling: Error thrown, cleanup attempted in finally block

Impact: Job fails, retry on next run

Failed Job Callbackโ€‹

const failedCb = async (job, err) => {
await FunnelStep.updateMany(
{ _id: job.data.step_id },
{
$unset: {
processing_queued: '',
thumbnail_build_in_progress: '',
thumbnail_process_started_at: '',
},
},
);
logger.error({
initiator: 'QM/funnels/build-thumbnails',
error: err,
additional_data: { job: job.id, job_data: job.data },
});
};

Actions:

  1. Clear processing flags (allows retry)
  2. Log error with job details

๐Ÿ“Š Monitoring & Loggingโ€‹

Success Loggingโ€‹

Cron Level:

  • Job started with unique jobId
  • Job skipped (previous still in progress)

Service Level:

  • No steps found to process
  • Found X steps requiring thumbnails
  • Added X jobs to queue
  • Completed processing: total X jobs

Queue Level:

  • Generated thumbnails for URL
  • Uploaded thumbnails for URL
  • Funnel thumbnail processed

Error Loggingโ€‹

Cron Level:

  • Error in thumbnail build process

Service Level:

  • Error scheduling build thumbnails

Queue Level:

  • Failed to generate screenshot
  • Failed to generate thumbnails
  • Error uploading thumbnail
  • Error in thumbnail generation process
  • Error during cleanup

Performance Metricsโ€‹

  • Typical Page Load: 5-15 seconds
  • Screenshot Generation: 2-5 seconds per viewport
  • Upload Time: 1-3 seconds per image
  • Total Job Time: 10-30 seconds per step
  • Concurrent Processing: 1 (sequential)

๐Ÿ”— Integration Pointsโ€‹

Triggers This Jobโ€‹

  • Cron Schedule: Every minute (no external triggers)
  • Database Flags: step_changed=true triggers inclusion in query

External Dependenciesโ€‹

  • Puppeteer: Headless Chrome for screenshots
  • Wasabi S3: Image storage
  • Preview Domain: preview.urlme.app for rendering pages

Jobs That Depend On Thisโ€‹

  • Funnel Publishing: Requires thumbnails for page previews
  • Funnel Listing: Displays thumbnails in funnel manager
  • Funnel Editor: Sets step_changed=true on save
  • Funnel Dashboard: Displays generated thumbnails

โš ๏ธ Important Notesโ€‹

Side Effectsโ€‹

  • โš ๏ธ Resource Usage: Puppeteer consumes significant CPU/memory
  • โš ๏ธ Storage Costs: Creates S3 objects (2 per step)
  • โš ๏ธ Temp Files: Created in OS temp directory (cleanup critical)
  • โš ๏ธ Browser Process: Singleton browser persists (memory leak risk)

Performance Considerationsโ€‹

  • Sequential Processing: One job at a time prevents resource exhaustion
  • Browser Reuse: Singleton browser pattern improves performance
  • Image Quality: 60% JPEG reduces file size without visible degradation
  • Stale Threshold: 2-hour threshold prevents indefinite stuck state
  • Job Cleanup: removeOnComplete and removeOnFail prevent queue bloat

Business Logicโ€‹

Why Every Minute?

  • Quick thumbnail generation for newly published pages
  • User expectations for fast preview updates

Why 2-Hour Stale Threshold?

  • Puppeteer can hang on complex pages
  • Network issues may cause timeouts
  • 2 hours balances recovery speed vs false positives

Why Two Viewports?

  • Mobile and desktop previews critical for responsive design
  • Users need to verify appearance on both devices

Why JPEG 60%?

  • Balances file size and quality
  • Sufficient for preview thumbnails (not print-quality)

Why Sequential Viewport Processing?

  • Reduces peak memory usage
  • Prevents resource contention
  • Acceptable performance tradeoff

Maintenance Notesโ€‹

  • Stale Threshold: 2 hours hardcoded (consider environment variable)
  • Viewport Sizes: Hardcoded (desktop: 1600x1000, mobile: 411x900)
  • JPEG Quality: 60% hardcoded (consider environment variable)
  • Browser Args: Extensive optimization flags (review periodically)
  • Concurrency: 1 hardcoded (consider scaling for high-volume systems)

Code Quality Issuesโ€‹

Issue 1: Browser Singleton Memory Leak Risk

let browser; // Never cleaned up

Suggestion: Implement browser lifecycle management:

// Close browser after idle period
let browserLastUsed = Date.now();
setInterval(() => {
if (Date.now() - browserLastUsed > 10 * 60 * 1000 && browser) {
browser.close();
browser = null;
}
}, 60000);

Issue 2: Hardcoded URLs

pageUrl: `https://preview.urlme.app/${step.funnel_id}${step.base_path}`,

Suggestion: Use environment variable:

pageUrl: `${process.env.PREVIEW_DOMAIN}/${step.funnel_id}${step.base_path}`,

Issue 3: Missing Done Call Might Cause Issues

const processCb = async (job, done) => {
try {
await generateThumbnailSet(job);
} finally {
done(); // Always called, even on error
}
};

Note: This is actually correct! The done() is called in finally block.

Issue 4: Viewport Settle Time Hardcoded

await new Promise(resolve => setTimeout(resolve, 2000)); // 2 seconds

Suggestion: Make configurable or use Puppeteer's waitForNetworkIdle.

๐Ÿงช Testingโ€‹

Manual Triggerโ€‹

# Via API (if QM_HOOKS=true)
POST http://localhost:6002/api/trigger/funnels/buildThumbnails

Flag Step for Thumbnail Generationโ€‹

const FunnelStep = await FunnelSteps.findOne({});

// Flag for regeneration
await FunnelStep.updateOne({ _id: FunnelStep._id }, { $set: { step_changed: true } });

console.log('Flagged step for thumbnail generation');

// Wait for next cron run (up to 1 minute)
// Or trigger manually via API

Verify Thumbnail Generationโ€‹

// Before generation
const beforeStep = await FunnelSteps.findById(stepId);
console.log('Before:', {
step_changed: beforeStep.step_changed,
thumbnails: beforeStep.thumbnails,
});

// Wait for processing

// After generation
const afterStep = await FunnelSteps.findById(stepId);
console.log('After:', {
step_changed: afterStep.step_changed,
thumbnails: afterStep.thumbnails,
thumbnails_updated_at: afterStep.thumbnails_updated_at,
});

// Verify S3 objects
console.log('Desktop URL:', afterStep.thumbnails.desktop.url);
console.log('Mobile URL:', afterStep.thumbnails.mobile.url);

Test Blank Page Handlingโ€‹

// Create step with blank content
const blankStep = await FunnelSteps.create({
name: 'Blank Test',
funnel_id: funnelId,
base_path: '/blank',
raw_html: '<html><body></body></html>',
step_changed: true,
});

// Trigger generation
await buildThumbnails();

// Verify no thumbnails generated
const result = await FunnelSteps.findById(blankStep._id);
console.log('Thumbnails:', result.thumbnails); // Should be null or empty
console.log('Flags cleared:', !result.step_changed && !result.processing_queued);

Test Stale Job Recoveryโ€‹

// Create stale processing state
await FunnelSteps.updateOne(
{ _id: stepId },
{
$set: {
processing_queued: true,
thumbnail_process_started_at: new Date(Date.now() - 3 * 60 * 60 * 1000), // 3 hours ago
},
},
);

// Trigger service
await buildThumbnails();

// Verify re-queued
setTimeout(async () => {
const step = await FunnelSteps.findById(stepId);
console.log('Re-queued:', step.processing_queued);
console.log('New timestamp:', step.thumbnail_process_started_at);
}, 5000);

Monitor Queueโ€‹

# Watch logs during thumbnail generation
tail -f logs/queue-manager.log | grep "build-thumbnails"

# Expected outputs:
# "Found X steps requiring thumbnail generation"
# "Generated thumbnails"
# "Uploaded thumbnails"
# "Funnel thumbnail processed."

Test Puppeteer Directlyโ€‹

const { getThumbnails } = require('./queue-manager/queues/funnels/buildThumbnails');

// Generate thumbnails for any URL
const thumbnails = await getThumbnails(
'https://preview.urlme.app/507f1f77bcf86cd799439011/landing',
);

console.log('Desktop:', thumbnails.desktop);
console.log('Mobile:', thumbnails.mobile);

// Verify temp files created
console.log('Desktop file exists:', fs.existsSync(thumbnails.desktop.filePath));
console.log('Mobile file exists:', fs.existsSync(thumbnails.mobile.filePath));

Job Type: Scheduled with High-Frequency Polling
Execution Frequency: Every minute
Average Duration: 10-30 seconds per step
Status: Active

๐Ÿ’ฌ

Documentation Assistant

Ask me anything about the docs

Hi! I'm your documentation assistant. Ask me anything about the docs!

I can help you with:
- Code examples
- Configuration details
- Troubleshooting
- Best practices

Try asking: How do I configure the API?
09:31 AM