From 6cb5ce170181f3c7a14d840fbde02738cebbf6ba Mon Sep 17 00:00:00 2001 From: Galib Sarayev Date: Fri, 19 Jun 2026 13:55:11 +0000 Subject: [PATCH] fix(core): send telemetry via background subprocess to prevent dropped events --- .changeset/fix-telemetry-send-subprocess.md | 10 ++ packages/core/src/telemetry/client.ts | 49 ++++------ .../src/telemetry/telemetry-send-worker.ts | 56 +++++++++++ packages/core/src/telemetry/telemetry.test.ts | 92 ++++++++++++++++++- packages/core/src/telemetry/trackCommand.ts | 4 +- 5 files changed, 178 insertions(+), 33 deletions(-) create mode 100644 .changeset/fix-telemetry-send-subprocess.md create mode 100644 packages/core/src/telemetry/telemetry-send-worker.ts diff --git a/.changeset/fix-telemetry-send-subprocess.md b/.changeset/fix-telemetry-send-subprocess.md new file mode 100644 index 00000000..ef50b2bc --- /dev/null +++ b/.changeset/fix-telemetry-send-subprocess.md @@ -0,0 +1,10 @@ +--- +"@aws-blocks/core": patch +--- + +fix(telemetry): send events via detached subprocess to prevent socket timeouts + +Telemetry events are now sent via a detached background subprocess instead of +in-process https.request. This fixes socket timeouts caused by execFileSync +blocking the event loop, and ensures events are delivered even when the parent +process exits on failure paths. diff --git a/packages/core/src/telemetry/client.ts b/packages/core/src/telemetry/client.ts index 291590f6..f4ccd32a 100644 --- a/packages/core/src/telemetry/client.ts +++ b/packages/core/src/telemetry/client.ts @@ -1,7 +1,7 @@ -import { request as httpsRequest } from 'node:https'; -import { request as httpRequest } from 'node:http'; import { existsSync, readFileSync, mkdirSync, openSync, writeSync, closeSync, writeFileSync, constants } from 'node:fs'; +import { spawn } from 'node:child_process'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { debuglog } from 'node:util'; import { CORE_VERSION } from '../version.js'; import { Scope } from '../common/index.js'; @@ -14,7 +14,6 @@ import type { BlocksTelemetryEvent, BuildAndSendEventOptions } from './types.js' const debug = debuglog('blocks-telemetry'); const DEFAULT_ENDPOINT = 'https://blocks-telemetry.us-east-1.api.aws/metrics'; -const TIMEOUT_MS = 500; const TELEMETRY_VERSION = '1.0.0'; function getEndpoint(): string { @@ -179,7 +178,9 @@ export function buildAndSendEvent(opts: BuildAndSendEventOptions): void { /** * Send a pre-built telemetry event to the collection endpoint. * - * Fire-and-forget: no retry, 500ms timeout, all errors silently swallowed. + * Spawns a detached subprocess that performs the HTTPS POST independently of + * the main process event loop. This avoids socket timeouts when execFileSync + * blocks the event loop, and survives parent process exit on failure paths. * Debug output available via `NODE_DEBUG=blocks-telemetry`. */ export function sendEvent(event: BlocksTelemetryEvent): void { @@ -194,32 +195,20 @@ export function sendEvent(event: BlocksTelemetryEvent): void { debug('sending event to %s (%d bytes)', endpoint, Buffer.byteLength(payload)); - const url = new URL(endpoint); - const isHttps = url.protocol === 'https:'; - const requestFn = isHttps ? httpsRequest : httpRequest; - - const req = requestFn( - { - hostname: url.hostname, - port: url.port || (isHttps ? '443' : '80'), - path: url.pathname + url.search, - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(payload), - }, - timeout: TIMEOUT_MS, - }, - (res) => { - debug('event sent (status=%d)', res.statusCode); - res.resume(); - }, - ); - - req.on('error', (err) => { debug('send failed: %s', err.message); }); - req.on('timeout', () => { debug('send timed out'); req.destroy(); }); - req.write(payload); - req.end(); + const dir = path.dirname(fileURLToPath(import.meta.url)); + const workerPath = path.join(dir, 'telemetry-send-worker.js'); + const child = spawn(process.execPath, [workerPath, endpoint], { + detached: true, + stdio: ['pipe', 'ignore', 'ignore'], + env: { ...process.env, NODE_OPTIONS: '' }, + }); + + child.stdin!.on('error', (err) => { debug('stdin write failed: %s', err.message); }); + child.stdin!.write(payload); + child.stdin!.end(); + child.on('error', (err) => { debug('spawn failed: %s', err.message); }); + child.unref(); + debug('spawned telemetry subprocess (pid=%d)', child.pid); } catch { // Telemetry must never throw or affect the user's command } diff --git a/packages/core/src/telemetry/telemetry-send-worker.ts b/packages/core/src/telemetry/telemetry-send-worker.ts new file mode 100644 index 00000000..f05e2bdd --- /dev/null +++ b/packages/core/src/telemetry/telemetry-send-worker.ts @@ -0,0 +1,56 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Telemetry send worker — spawned as a detached subprocess. + * Reads JSON payload from stdin, POSTs it to the endpoint (argv[2]). + * + * Uses only Node built-ins — no project imports — so the compiled .js + * runs with bare `node` (no tsx needed). + */ + +import { request as httpsRequest } from 'node:https'; +import { request as httpRequest } from 'node:http'; + +const TIMEOUT_MS = 500; +const debug = (process.env.NODE_DEBUG || '').includes('blocks-telemetry'); + +const endpoint = process.argv[2]; +if (!endpoint) process.exit(1); + +let payload = ''; +process.stdin.setEncoding('utf-8'); +process.stdin.on('data', (chunk: string) => { payload += chunk; }); +process.stdin.on('end', () => { + const url = new URL(endpoint); + const isHttps = url.protocol === 'https:'; + const requestFn = isHttps ? httpsRequest : httpRequest; + + const req = requestFn({ + hostname: url.hostname, + port: url.port || (isHttps ? '443' : '80'), + path: url.pathname + url.search, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(payload), + }, + timeout: TIMEOUT_MS, + }, (res) => { + res.resume(); + if (debug) process.stderr.write(`BLOCKS-TELEMETRY: sent (status=${res.statusCode})\n`); + process.exit(0); + }); + + req.on('error', (e) => { + if (debug) process.stderr.write(`BLOCKS-TELEMETRY: error: ${(e as Error).message}\n`); + process.exit(1); + }); + req.on('timeout', () => { + if (debug) process.stderr.write(`BLOCKS-TELEMETRY: timed out\n`); + req.destroy(); + process.exit(1); + }); + req.write(payload); + req.end(); +}); diff --git a/packages/core/src/telemetry/telemetry.test.ts b/packages/core/src/telemetry/telemetry.test.ts index e41cb272..ffbc12b5 100644 --- a/packages/core/src/telemetry/telemetry.test.ts +++ b/packages/core/src/telemetry/telemetry.test.ts @@ -11,7 +11,7 @@ import { isCI, detectOS, detectNodeVersion, detectPackageManager, detectAgent, c import { trackCommand, classifyError } from './trackCommand.js'; import { buildAndSendEvent, buildEvent, sendEvent, getTelemetryFilePath } from './client.js'; import { getInstallationId, getProjectId, generateEventId } from './identifiers.js'; -import { spawnSync } from 'node:child_process'; +import { spawnSync, spawn as spawnChild } from 'node:child_process'; import type { BlocksTelemetryEvent } from './types.js'; import { Scope, OFFICIAL_BB_NAMES } from '../common/index.js'; import type { ScopeParent } from '../common/index.js'; @@ -533,6 +533,96 @@ describe('telemetry/client', () => { }); }); +describe('telemetry/send-worker', () => { + it('worker POSTs payload from stdin to endpoint', async () => { + const received: string[] = []; + + const server: Server = await new Promise((resolve) => { + const s = createServer((req, res) => { + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + received.push(body); + res.writeHead(200); + res.end(); + }); + }); + s.listen(0, '127.0.0.1', () => resolve(s)); + }); + + const addr = server.address() as { port: number }; + const endpoint = `http://127.0.0.1:${addr.port}/collect`; + const payload = JSON.stringify({ test: true, command: 'dev' }); + const workerPath = join(__dirname, 'telemetry-send-worker.js'); + + const exitCode = await new Promise((resolve) => { + const proc = spawnChild(process.execPath, [workerPath, endpoint], { + stdio: ['pipe', 'ignore', 'ignore'], + env: { ...process.env, NODE_OPTIONS: '' }, + }); + proc.stdin!.write(payload); + proc.stdin!.end(); + proc.on('close', (code) => resolve(code)); + }); + + assert.strictEqual(exitCode, 0); + assert.strictEqual(received.length, 1); + assert.deepStrictEqual(JSON.parse(received[0]), { test: true, command: 'dev' }); + + server.close(); + }); + + it('worker exits with 1 on unreachable endpoint', async () => { + const payload = JSON.stringify({ test: true }); + const workerPath = join(__dirname, 'telemetry-send-worker.js'); + + const exitCode = await new Promise((resolve) => { + const proc = spawnChild(process.execPath, [workerPath, 'http://127.0.0.1:1/unreachable'], { + stdio: ['pipe', 'ignore', 'ignore'], + env: { ...process.env, NODE_OPTIONS: '' }, + }); + proc.stdin!.write(payload); + proc.stdin!.end(); + proc.on('close', (code) => resolve(code)); + }); + + assert.strictEqual(exitCode, 1); + }); + + it('worker writes debug output to stderr when NODE_DEBUG is set', async () => { + const server: Server = await new Promise((resolve) => { + const s = createServer((req, res) => { + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { res.writeHead(200); res.end(); }); + }); + s.listen(0, '127.0.0.1', () => resolve(s)); + }); + + const addr = server.address() as { port: number }; + const endpoint = `http://127.0.0.1:${addr.port}/collect`; + const payload = JSON.stringify({ test: true }); + const workerPath = join(__dirname, 'telemetry-send-worker.js'); + + const result = await new Promise<{ code: number | null; stderr: string }>((resolve) => { + const proc = spawnChild(process.execPath, [workerPath, endpoint], { + stdio: ['pipe', 'ignore', 'pipe'], + env: { ...process.env, NODE_OPTIONS: '', NODE_DEBUG: 'blocks-telemetry' }, + }); + let stderr = ''; + proc.stderr!.on('data', (chunk: Buffer) => { stderr += chunk.toString(); }); + proc.stdin!.write(payload); + proc.stdin!.end(); + proc.on('close', (code) => resolve({ code, stderr })); + }); + + assert.strictEqual(result.code, 0); + assert.ok(result.stderr.includes('BLOCKS-TELEMETRY: sent (status=200)'), `Expected debug output, got: ${result.stderr}`); + + server.close(); + }); +}); + describe('telemetry/trackCommand integration', () => { const originalEnv = { ...process.env }; diff --git a/packages/core/src/telemetry/trackCommand.ts b/packages/core/src/telemetry/trackCommand.ts index 8af0b755..642eeb69 100644 --- a/packages/core/src/telemetry/trackCommand.ts +++ b/packages/core/src/telemetry/trackCommand.ts @@ -22,10 +22,10 @@ export function classifyError(error: unknown): { code: string; phase: string } { if (msg.includes('cdk synth') || msg.includes('synthesis')) { return { code: 'CDK_SYNTH_FAILED', phase: 'synth' }; } - if (msg.includes('cdk deploy') || msg.includes('deployment failed')) { + if (msg.includes('cdk deploy') || msg.includes('deployment failed') || (msg.includes('cdk') && msg.includes('deploy') && msg.includes('exited with code'))) { return { code: 'CDK_DEPLOY_FAILED', phase: 'deploy' }; } - if (msg.includes('cdk destroy') || msg.includes('destroy failed')) { + if (msg.includes('cdk destroy') || msg.includes('destroy failed') || (msg.includes('cdk') && msg.includes('destroy') && msg.includes('exited with code'))) { return { code: 'CDK_DESTROY_FAILED', phase: 'destroy' }; } if (msg.includes('npm install') || msg.includes('npm err')) {