Skip to content
Merged
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
4 changes: 2 additions & 2 deletions src/featureFlag/FeatureFlagProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,13 +84,13 @@ export class FeatureFlagProvider implements Closeable {
this.log();
}

@Measure({ name: 'getFeatureFlags' })
@Measure({ name: 'getFeatureFlags', captureErrorAttributes: true })
private async getFeatureFlags(env: string): Promise<unknown> {
try {
return await this.getLatestFeatureFlags(env);
} catch (error) {
if (isClientNetworkError(error)) {
this.telemetry.count('getFeatureFlags.clientNetworkError', 1);
this.telemetry.error('getFeatureFlags.clientNetworkError', error);
log.info('Skipping feature flag refresh due to client network error');
return this.config;
}
Expand Down
2 changes: 1 addition & 1 deletion src/schema/GetSamSchemaTask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export class GetSamSchemaTask extends GetSchemaTask {
this.logger.info(`${resourceSchemas.size} SAM schemas downloaded and saved`);
} catch (error) {
if (isClientNetworkError(error)) {
this.telemetry.count('getSchemas.clientNetworkError', 1);
this.telemetry.error('getSchemas.clientNetworkError', error);
this.logger.info('Skipping SAM schemas due to client network error');
return;
}
Expand Down
4 changes: 3 additions & 1 deletion src/schema/GetSchemaTask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,9 @@ export class GetPublicSchemaTask extends GetSchemaTask {
this.logger.info(`${schemas.length} public schemas downloaded for ${this.region} and saved`);
} catch (error) {
if (isClientNetworkError(error)) {
this.telemetry.count('getSchemas.clientNetworkError', 1, { attributes: { region: this.region } });
this.telemetry.error('getSchemas.clientNetworkError', error, undefined, {
attributes: { region: this.region },
});
this.logger.info(`Skipping public schemas for ${this.region} due to client network error`);
return;
}
Expand Down
21 changes: 7 additions & 14 deletions src/telemetry/ScopedTelemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
ValueType,
} from '@opentelemetry/api';
import { Closeable } from '../utils/Closeable';
import { errorAttributes, errorType } from '../utils/Errors';
import { errorAttributes, errorType } from '../utils/ErrorStackInfo';
import { hasSuppressFault } from '../utils/FaultSuppression';
import { typeOf } from '../utils/TypeCheck';
import { TelemetryContext } from './TelemetryContext';
Expand Down Expand Up @@ -56,19 +56,12 @@ export class ScopedTelemetry implements Closeable {
}

error(name: string, error: unknown, origin?: 'uncaughtException' | 'unhandledRejection', config?: MetricConfig) {
if (config?.captureErrorAttributes) {
config.attributes = {
...config.attributes,
...errorType(error),
...errorAttributes(error, origin),
};
} else if (config?.captureErrorType) {
config.attributes = {
...config.attributes,
...errorType(error),
};
}
this.count(name, 1, config);
const attributes: Attributes = {
...config?.attributes,
...errorType(error),
...(config?.captureErrorAttributes ? errorAttributes(error, origin) : {}),
};
this.count(name, 1, { ...config, attributes });
}

registerGaugeProvider(name: string, provider: () => number, config?: MetricConfig): void {
Expand Down
13 changes: 4 additions & 9 deletions src/utils/AwsErrorMapper.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ErrorCodes, ResponseError } from 'vscode-languageserver';
import { extractErrorMessage } from './Errors';
import { CLIENT_NETWORK_ERROR_CODES, extractErrorMessage } from './Errors';
import { createOnlineFeatureError, OnlineFeatureErrorCode } from './OnlineFeatureError';

type AwsError = {
Expand All @@ -21,14 +21,9 @@ const CREDENTIAL_ERROR_NAMES = new Set([
'ExpiredTokenException',
]);

const NETWORK_ERROR_NAMES = new Set([
'NetworkingError',
'TimeoutError',
'ENOTFOUND',
'ECONNREFUSED',
'ETIMEDOUT',
'ECONNRESET',
]);
const AWS_NETWORK_ERROR_NAMES: ReadonlySet<string> = new Set(['NetworkingError', 'TimeoutError']);

const NETWORK_ERROR_NAMES: ReadonlySet<string> = new Set([...AWS_NETWORK_ERROR_NAMES, ...CLIENT_NETWORK_ERROR_CODES]);

const RETRYABLE_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504]);

Expand Down
80 changes: 59 additions & 21 deletions src/utils/ErrorStackInfo.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,66 @@
import { LoggerFactory } from '../telemetry/LoggerFactory';
import { Attributes } from '@opentelemetry/api';
import { classifyAwsError } from './AwsErrorMapper';
import { extractRootCause, extractErrorCode, extractHttpStatus } from './Errors';
import { sanitizeMessage } from './Sanitizer';

let errorStackInfo: string[] | undefined;
/**
* Best effort extraction of location of exception based on stack trace
*/
export function extractLocationFromStack(stack?: string): Record<string, string> {
if (!stack) return {};

export function determineSensitiveInfo(): string[] {
if (errorStackInfo !== undefined) {
return errorStackInfo;
const lines = sanitizeMessage(stack).split('\n');

if (lines.length === 0) {
return {};
}

try {
errorStackInfo = __dirname
.replaceAll('\\\\', '/')
.replaceAll('\\', '/')
.split(/[/:]/)
.map((x) => {
return x.trim();
})
.filter((x) => {
return x.length > 1;
});
} catch (err) {
LoggerFactory.getLogger('SensitiveInfo').warn(err, 'Cannot get __dirname');
errorStackInfo = [];
return {
['error.message']: lines[0],
['error.stack']: lines.slice(1).join('\n'),
};
}

export function errorAttributes(error: unknown, origin?: 'uncaughtException' | 'unhandledRejection'): Attributes {
const location = error instanceof Error ? extractLocationFromStack(error.stack) : {};
const cause = extractRootCause(error);
const causeLocation = cause ? extractLocationFromStack(cause.stack) : {};

return {
'error.origin': origin ?? 'Unknown',
...location,
...(causeLocation['error.message'] !== undefined && { 'error.cause.message': causeLocation['error.message'] }),
...(causeLocation['error.stack'] !== undefined && { 'error.cause.stack': causeLocation['error.stack'] }),
};
}

export function errorType(error: unknown): Attributes {
const type = error instanceof Error ? error.name : typeof error;
const code = extractErrorCode(error);

const cause = extractRootCause(error);
const status = extractHttpStatus(error);
const causeStatus = cause ? extractHttpStatus(cause) : undefined;

const awsClassification = classifyAwsError(error);
const awsAttr: Record<string, string> = {};
if (awsClassification.category !== 'unknown') {
awsAttr['error.aws.category'] = sanitizeMessage(awsClassification.category);
}
if (awsClassification.httpStatus) {
awsAttr['error.aws.http.status'] = sanitizeMessage(`${awsClassification.httpStatus}`);
}

return {
'error.type': sanitizeMessage(type),
'error.code': sanitizeMessage(code ?? 'Unknown'),

errorStackInfo = [__dirname.trim(), ...errorStackInfo];
return errorStackInfo;
...(status !== undefined && { 'error.http.status': status }),
...(cause && {
'error.cause.type': sanitizeMessage(cause.name),
'error.cause.code': sanitizeMessage(extractErrorCode(cause) ?? 'Unknown'),
...(causeStatus !== undefined && { 'error.cause.http.status': sanitizeMessage(`${causeStatus}`) }),
}),
...awsAttr,
};
}
180 changes: 109 additions & 71 deletions src/utils/Errors.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,58 @@
import { Attributes } from '@opentelemetry/api';
import { ErrorCodes, ResponseError } from 'vscode-languageserver';
import { determineSensitiveInfo } from './ErrorStackInfo';
import { toString } from './String';

const CLIENT_NETWORK_ERROR_PATTERNS = [
'unable to get local issuer certificate',
'self signed certificate',
'unable to verify the first certificate',
'certificate has expired',
'does not match certificate',
'WRONG_VERSION_NUMBER',
'ECONNRESET',
'ETIMEDOUT',
'ECONNREFUSED',
'ENOTFOUND',
'EAI_AGAIN',
'ECONNABORTED',
'EBADF',
'socket hang up',
'network socket disconnected',
'TOO_MANY_REDIRECTS',
'Parse Error: Expected HTTP',
'status code 407',
export const CLIENT_NETWORK_ERROR_CODES: ReadonlySet<string> = new Set([
'ECONNRESET', // Peer reset the connection mid-stream
'ETIMEDOUT', // Operation exceeded its timeout
'ECONNREFUSED', // Server actively refused (port closed / not listening)
'ENOTFOUND', // DNS resolution failed
'EAI_AGAIN', // Transient DNS resolver failure (retry-eligible)
'ECONNABORTED', // Local socket aborted (often an axios timeout)
'EPIPE', // Wrote to a socket the peer already closed
'EHOSTUNREACH', // No route to host (firewall / unreachable)
'ENETUNREACH', // Network unreachable (offline / interface down)
'NGHTTP2_REFUSED_STREAM', // HTTP/2 server refused a new stream (transient)
]);

const CLIENT_NETWORK_ERROR_MESSAGE_PATTERNS = [
'unable to get local issuer certificate', // Cert chain root not in client trust store
'self signed certificate', // Peer presented a self-signed certificate
'unable to verify the first certificate', // Incomplete server cert chain
'certificate has expired', // Server cert past validity window
'does not match certificate', // Hostname / SAN mismatch
'WRONG_VERSION_NUMBER', // TLS version mismatch (often plaintext on a TLS port)
'socket hang up', // Peer closed connection before responding
'network socket disconnected', // Underlying socket dropped mid-request
'TOO_MANY_REDIRECTS', // Client exceeded redirect limit (e.g. ERR_FR_TOO_MANY_REDIRECTS)
'Parse Error: Expected HTTP', // Non-HTTP response on HTTP port (proxy / wrong protocol)
'status code 407', // Proxy Authentication Required
];

export function isClientNetworkError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return CLIENT_NETWORK_ERROR_PATTERNS.some((pattern) => message.toLowerCase().includes(pattern.toLowerCase()));
const parts: string[] = [extractErrorMessage(error)];

if (typeof error === 'object' && error !== null) {
const { code, name } = error as { code?: unknown; name?: unknown };
if (typeof code === 'string') {
parts.push(code);
}
if (typeof name === 'string') {
parts.push(name);
}
}

const haystack = parts.join(' ').toLowerCase();

// Match canonical codes (errno-style) and free-form message patterns
for (const code of CLIENT_NETWORK_ERROR_CODES) {
if (haystack.includes(code.toLowerCase())) {
return true;
}
}

return CLIENT_NETWORK_ERROR_MESSAGE_PATTERNS.some((pattern) => {
return haystack.includes(pattern.toLowerCase());
});
}

export function extractStatusReason(error: unknown): string | undefined {
Expand Down Expand Up @@ -60,62 +86,74 @@ export function handleLspError(error: unknown, contextMessage: string): never {
throw new ResponseError(ErrorCodes.InternalError, `${contextMessage}: ${extractErrorMessage(error)}`);
}

/**
* Best effort extraction of location of exception based on stack trace
*/
export function extractLocationFromStack(stack?: string): Record<string, string> {
if (!stack) return {};

const lines = stack
.trim()
.split('\n')
.map((line) => {
let newLine = line.trim();
for (const word of determineSensitiveInfo()) {
if (word !== 'aws' && word !== 'cloudformation-languageserver') {
newLine = newLine.replaceAll(word, '[*]');
}
}

return newLine.replaceAll('\\\\', '/').replaceAll('\\', '/');
})
.map((line) => {
return sanitizeErrorMessage(line);
});

if (lines.length === 0) {
return {};
}

return {
['error.message']: lines[0],
['error.stack']: lines.slice(1).join('\n'),
};
}
export function extractRootCause(error: unknown): Error | undefined {
if (error === null || typeof error !== 'object') {
return undefined;
}

function sanitizeErrorMessage(message: string): string {
return message
.replaceAll(/arn:aws[^:\s]*:\S+\d{12}\S*/gi, 'arn:aws:<REDACTED>')
.replaceAll(/\b\d{12}\b/g, '<ACCOUNT_ID>');
const errorAs = error as { commitError?: unknown; cause?: unknown };

if (errorAs.commitError instanceof Error) {
return errorAs.commitError;
}

if (errorAs.cause instanceof Error) {
return errorAs.cause;
}

return undefined;
}

export function errorAttributes(error: unknown, origin?: 'uncaughtException' | 'unhandledRejection'): Attributes {
const location = error instanceof Error ? extractLocationFromStack(error.stack) : {};
export function extractErrorCode(error: unknown): string | undefined {
if (error === null || typeof error !== 'object') {
return undefined;
}

const { code, Code, CODE, errno } = error as { code?: unknown; Code?: unknown; CODE?: unknown; errno?: number };

return {
'error.origin': origin ?? 'Unknown',
...location,
};
if (typeof code === 'string') {
return code;
}

if (typeof Code === 'string') {
return Code;
}

if (typeof CODE === 'string') {
return CODE;
}

if (typeof errno === 'number') {
return `${errno}`;
}

return undefined;
}

export function errorType(error: unknown): Attributes {
const type = error instanceof Error ? error.name : typeof error;
const code = error !== null && typeof error === 'object' ? (error as NodeJS.ErrnoException).code : undefined;
export function extractHttpStatus(error: unknown): number | undefined {
if (error === null || typeof error !== 'object') {
return undefined;
}

return {
'error.type': type,
'error.code': code ?? 'Unknown',
const candidate = error as {
$metadata?: { httpStatusCode?: number };
response?: { status?: number };
status?: number;
};

if (typeof candidate.$metadata?.httpStatusCode === 'number') {
return candidate.$metadata?.httpStatusCode;
}

if (typeof candidate.response?.status === 'number') {
return candidate.response?.status;
}

if (typeof candidate.status === 'number') {
return candidate.status;
}

return undefined;
}

export class DoesNotExist extends Error {
Expand Down
Loading