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
10 changes: 10 additions & 0 deletions .changeset/fix-telemetry-send-subprocess.md
Original file line number Diff line number Diff line change
@@ -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.
49 changes: 19 additions & 30 deletions packages/core/src/telemetry/client.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -14,7 +14,6 @@
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 {
Expand Down Expand Up @@ -64,7 +63,7 @@
const content = JSON.stringify([event], null, 2);
writeSync(fd, content);
closeSync(fd);
} catch (err: any) {

Check warning on line 66 in packages/core/src/telemetry/client.ts

View workflow job for this annotation

GitHub Actions / Build, Unit Tests, E2E Local

lint/correctness/noUnusedVariables

This variable err is unused.
// EEXIST = file already existed → skip (protects user data)
// All other errors silently ignored — telemetry must never affect commands
}
Expand Down Expand Up @@ -179,7 +178,9 @@
/**
* 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 {
Expand All @@ -194,32 +195,20 @@

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); });

Check warning on line 206 in packages/core/src/telemetry/client.ts

View workflow job for this annotation

GitHub Actions / Build, Unit Tests, E2E Local

lint/style/noNonNullAssertion

Forbidden non-null assertion.
child.stdin!.write(payload);

Check warning on line 207 in packages/core/src/telemetry/client.ts

View workflow job for this annotation

GitHub Actions / Build, Unit Tests, E2E Local

lint/style/noNonNullAssertion

Forbidden non-null assertion.
Comment thread
soberm marked this conversation as resolved.
child.stdin!.end();

Check warning on line 208 in packages/core/src/telemetry/client.ts

View workflow job for this annotation

GitHub Actions / Build, Unit Tests, E2E Local

lint/style/noNonNullAssertion

Forbidden non-null assertion.
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
}
Expand Down
56 changes: 56 additions & 0 deletions packages/core/src/telemetry/telemetry-send-worker.ts
Original file line number Diff line number Diff line change
@@ -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();
});
92 changes: 91 additions & 1 deletion packages/core/src/telemetry/telemetry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
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';
Expand Down Expand Up @@ -533,6 +533,96 @@
});
});

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<number | null>((resolve) => {
const proc = spawnChild(process.execPath, [workerPath, endpoint], {
stdio: ['pipe', 'ignore', 'ignore'],
env: { ...process.env, NODE_OPTIONS: '' },
});
proc.stdin!.write(payload);

Check warning on line 563 in packages/core/src/telemetry/telemetry.test.ts

View workflow job for this annotation

GitHub Actions / Build, Unit Tests, E2E Local

lint/style/noNonNullAssertion

Forbidden non-null assertion.
proc.stdin!.end();

Check warning on line 564 in packages/core/src/telemetry/telemetry.test.ts

View workflow job for this annotation

GitHub Actions / Build, Unit Tests, E2E Local

lint/style/noNonNullAssertion

Forbidden non-null assertion.
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<number | null>((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);

Check warning on line 584 in packages/core/src/telemetry/telemetry.test.ts

View workflow job for this annotation

GitHub Actions / Build, Unit Tests, E2E Local

lint/style/noNonNullAssertion

Forbidden non-null assertion.
proc.stdin!.end();

Check warning on line 585 in packages/core/src/telemetry/telemetry.test.ts

View workflow job for this annotation

GitHub Actions / Build, Unit Tests, E2E Local

lint/style/noNonNullAssertion

Forbidden non-null assertion.
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(); });

Check warning on line 613 in packages/core/src/telemetry/telemetry.test.ts

View workflow job for this annotation

GitHub Actions / Build, Unit Tests, E2E Local

lint/style/noNonNullAssertion

Forbidden non-null assertion.
proc.stdin!.write(payload);

Check warning on line 614 in packages/core/src/telemetry/telemetry.test.ts

View workflow job for this annotation

GitHub Actions / Build, Unit Tests, E2E Local

lint/style/noNonNullAssertion

Forbidden non-null assertion.
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 };

Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/telemetry/trackCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')) {
Expand Down
Loading