Skip to content

Instantly share code, notes, and snippets.

@JacksonWeber
Created March 21, 2026 02:24
Show Gist options
  • Select an option

  • Save JacksonWeber/9cbe5c502aefab5fffded48daa21d09c to your computer and use it in GitHub Desktop.

Select an option

Save JacksonWeber/9cbe5c502aefab5fffded48daa21d09c to your computer and use it in GitHub Desktop.
Workaround for ApplicationInsights-node.js #1411 - Error.cause chain support

Error.cause Chain Workaround for Application Insights Node.js

Workaround for microsoft/ApplicationInsights-node.js#1411.

Problem

Node.js v16.9+ supports Error.cause for chaining exceptions, but the Application Insights Node.js SDK only captures the top-level error. The TelemetryExceptionData.exceptions array always contains a single entry even though the schema supports chains via id/outerId.

Why a custom SpanProcessor/LogRecordProcessor can't solve this

By the time a processor's onEnd() or onEmit() runs, the original Error object has already been serialized into flat OTel attributes (exception.type, exception.message, exception.stacktrace). The .cause property is lost during this serialization — there is no standard OTel attribute for exception cause chains.

Workaround

The Azure Monitor exporter has a legacy API path: when a log record has the attribute _MS.baseType set to "ExceptionData", the exporter uses log.body directly as TelemetryExceptionData. This lets us construct properly formatted exception telemetry with the full cause chain.

trackExceptionWithCauses(error, severityLevel?, customProperties?, maxDepth?)

A helper function that:

  1. Traverses the Error.cause chain
  2. Builds an array of TelemetryExceptionDetails linked via id/outerId
  3. Emits a single log record with _MS.baseType = "ExceptionData" and the full chain as body

Usage

import { trackExceptionWithCauses } from "./trackExceptionWithCauses.js";

try {
    await fetchUserProfile(userId);
} catch (error) {
    // Sends ALL exceptions in the cause chain as a single telemetry item
    trackExceptionWithCauses(error, "Error", { "user.id": userId });
}

Resulting telemetry structure

For an error like:

Error: Service failed
  cause: Error: Repository failed
    cause: Error: ECONNREFUSED

The exceptions array in Application Insights will contain:

[
  { "id": 0, "typeName": "Error", "message": "Service failed", "stack": "..." },
  { "id": 1, "outerId": 0, "typeName": "Error", "message": "Repository failed", "stack": "..." },
  { "id": 2, "outerId": 1, "typeName": "Error", "message": "ECONNREFUSED", "stack": "..." }
]

Setup

npm install
# Set your connection string
export APPLICATIONINSIGHTS_CONNECTION_STRING="InstrumentationKey=..."
npm start
/**
* Demo: Error.cause chain tracking with Azure Monitor / Application Insights.
* Workaround for https://github.com/microsoft/ApplicationInsights-node.js/issues/1411
*/
import { useAzureMonitor, shutdownAzureMonitor } from "@azure/monitor-opentelemetry";
import { trace, SpanStatusCode, SpanKind } from "@opentelemetry/api";
import { trackExceptionWithCauses } from "./trackExceptionWithCauses";
useAzureMonitor({
azureMonitorExporterOptions: {
connectionString:
// YOUR_CONNECTION_STRING_HERE,
},
// tracesPerSecond: 0 disables the rate-limited sampler so all spans are exported
tracesPerSecond: 0,
samplingRatio: 1.0,
});
const tracer = trace.getTracer("error-cause-demo");
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function simulateRequest(): Promise<void> {
// Outer SERVER span — shows as a Request in App Insights
await tracer.startActiveSpan("GET /api/users/123", { kind: SpanKind.SERVER }, async (requestSpan) => {
requestSpan.setAttribute("http.method", "GET");
requestSpan.setAttribute("http.url", "https://myapp.com/api/users/123");
requestSpan.setAttribute("http.status_code", 500);
try {
// Inner CLIENT span — shows as a Dependency (DB call)
await tracer.startActiveSpan("SELECT * FROM users", { kind: SpanKind.CLIENT }, async (dbSpan) => {
dbSpan.setAttribute("db.system", "postgresql");
dbSpan.setAttribute("db.statement", "SELECT * FROM users WHERE id = $1");
dbSpan.setAttribute("net.peer.name", "10.0.0.5");
dbSpan.setAttribute("net.peer.port", 5432);
await sleep(50); // simulate DB latency
// Simulate the failure
const dbError = new Error("ECONNREFUSED: Connection refused to 10.0.0.5:5432");
const repoError = new Error("Failed to fetch user record from database", { cause: dbError });
dbSpan.setStatus({ code: SpanStatusCode.ERROR, message: dbError.message });
dbSpan.recordException(dbError);
dbSpan.end();
throw repoError;
});
} catch (err) {
const serviceError = new Error("UserService.getProfile() failed", { cause: err as Error });
console.log("=== Error cause chain ===");
console.log("Outer:", serviceError.message);
console.log(" Cause:", (serviceError.cause as Error)?.message);
console.log(" Root:", ((serviceError.cause as Error)?.cause as Error)?.message);
console.log();
// Track the full cause chain as a single exception telemetry item
console.log("Sending exception with full cause chain...");
trackExceptionWithCauses(serviceError, "Error", {
"custom.operation": "getProfile",
"custom.userId": "user-123",
});
requestSpan.setStatus({ code: SpanStatusCode.ERROR, message: serviceError.message });
}
requestSpan.end();
});
}
simulateRequest().then(async () => {
console.log("Telemetry sent. Flushing all providers...");
// Force flush traces — the proxy doesn't have forceFlush, so get the delegate
const tracerProvider = trace.getTracerProvider() as any;
const delegate = tracerProvider.getDelegate?.() ?? tracerProvider;
if (typeof delegate.forceFlush === "function") {
await delegate.forceFlush();
console.log(" Traces flushed.");
}
// Force flush logs
const { logs } = await import("@opentelemetry/api-logs");
const loggerProvider = logs.getLoggerProvider() as any;
const logDelegate = loggerProvider.getDelegate?.() ?? loggerProvider;
if (typeof logDelegate.forceFlush === "function") {
await logDelegate.forceFlush();
console.log(" Logs flushed.");
}
// Small delay for network, then shutdown
await sleep(3000);
await shutdownAzureMonitor();
console.log("Done. The trace should contain:");
console.log(" 1. Request: GET /api/users/123 (SERVER span)");
console.log(" 2. Dependency: SELECT * FROM users (CLIENT span — DB call)");
console.log(" 3. Exception: UserService.getProfile() failed (with 3-level cause chain)");
});
{
"name": "error-cause-workaround",
"version": "1.0.0",
"description": "Workaround for ApplicationInsights-node.js issue #1411 - Error.cause chain support",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "tsc && node dist/index.js"
},
"dependencies": {
"@azure/monitor-opentelemetry": "^1.16.0",
"@azure/monitor-opentelemetry-exporter": "^1.0.0-beta.39",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.57.0",
"@opentelemetry/sdk-logs": "^0.57.0"
},
"devDependencies": {
"typescript": "^5.5.0"
}
}
/**
* Workaround for https://github.com/microsoft/ApplicationInsights-node.js/issues/1411
*
* Sends exception telemetry with the full Error.cause chain using the
* Azure Monitor exporter's legacy API path (_MS.baseType = "ExceptionData").
*/
import type { AnyValueMap } from "@opentelemetry/api-logs";
import { logs, SeverityNumber } from "@opentelemetry/api-logs";
interface ExceptionDetail {
id: number;
outerId?: number;
typeName: string;
message: string;
hasFullStack: boolean;
stack: string;
}
interface ExceptionData {
exceptions: ExceptionDetail[];
severityLevel: string;
version: number;
properties: Record<string, string>;
}
type SeverityLevel = "Verbose" | "Information" | "Warning" | "Error" | "Critical";
/**
* Traverses an Error's cause chain and emits a single exception telemetry item
* containing all exceptions linked via id/outerId, matching the Application Insights
* exception chain format (similar to the Java SDK).
*/
export function trackExceptionWithCauses(
error: Error,
severityLevel: SeverityLevel = "Error",
customProperties: Record<string, string> = {},
maxDepth: number = 10,
): void {
const logger = logs.getLogger("error-cause-tracker");
const exceptions = buildExceptionChain(error, maxDepth);
const exceptionData: ExceptionData = {
exceptions,
severityLevel,
version: 2,
properties: customProperties,
};
logger.emit({
// The exporter reads log.body as TelemetryExceptionData when _MS.baseType is set.
// Cast through AnyValueMap to satisfy the OTel LogRecord.body type.
body: exceptionData as unknown as AnyValueMap,
attributes: {
"_MS.baseType": "ExceptionData",
},
severityNumber: mapSeverityToNumber(severityLevel),
});
}
/**
* Builds an array of TelemetryExceptionDetails from an Error's cause chain.
* Each exception is linked to its parent via outerId.
*/
function buildExceptionChain(error: Error, maxDepth: number): ExceptionDetail[] {
const exceptions: ExceptionDetail[] = [];
let current: Error | undefined = error;
let id = 0;
const seen = new WeakSet<Error>();
while (current && id < maxDepth) {
if (seen.has(current)) break;
seen.add(current);
exceptions.push({
id,
outerId: id > 0 ? id - 1 : undefined,
typeName: current.name || "Error",
message: current.message || "Unknown error",
hasFullStack: !!current.stack,
stack: current.stack || "",
});
current = (current as Error & { cause?: Error }).cause;
id++;
}
return exceptions;
}
function mapSeverityToNumber(level: SeverityLevel): SeverityNumber {
switch (level) {
case "Verbose": return SeverityNumber.DEBUG;
case "Information": return SeverityNumber.INFO;
case "Warning": return SeverityNumber.WARN;
case "Error": return SeverityNumber.ERROR;
case "Critical": return SeverityNumber.FATAL;
}
}
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment