Skip to content
Merged
247 changes: 232 additions & 15 deletions models/SentryService.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -344,8 +344,8 @@ component accessors=true singleton {
* @level The level to log
* @path The path to the script currently executing
* @oneLineStackTrace Set to true to render only 1 tag context. This is not the Java Stack Trace this is simply for the code output in Sentry
* @showJavaStackTrace Passes Java Stack Trace as a string to the extra attribute
* @removeTabsOnJavaStackTrace Removes the tab on the child lines in the Stack Trace
* @showJavaStackTrace When true, parses the Java stack trace and sends it as structured exception entries in exception.values with proper Sentry frames.
* @removeTabsOnJavaStackTrace Deprecated — no longer needed. Kept for backward compatibility.
* @additionalData Additional metadata to store with the event - passed into the extra attribute
* @cgiVars Parameters to send to Sentry, defaults to the CGI Scope
* @useThread Option to send post to Sentry in its own thread
Expand Down Expand Up @@ -422,16 +422,8 @@ component accessors=true singleton {
sentryException.message = arguments.message & " " & sentryException.message;
}

if ( arguments.showJavaStackTrace ) {
st = reReplace(
arguments.exception.StackTrace,
"\r",
"",
"All"
);
if ( arguments.removeTabsOnJavaStackTrace ) st = reReplace( st, "\t", "", "All" );
sentryExceptionExtra[ "Java StackTrace" ] = listToArray( st, chr( 10 ) );
}
// Java stack trace is now sent as a structured exception entry in exception.values
// via parseJavaStackTrace() below, not as a raw text blob in extra.

if ( !isNull( arguments.additionalData ) ) {
sentryExceptionExtra[ "Additional Data" ] = arguments.additionalData;
Expand Down Expand Up @@ -507,10 +499,17 @@ component accessors=true singleton {
"type" : arguments.exception.type & " Error",
"stacktrace" : { "frames" : [] }
};

sentryException[ "exception" ] = { "values" : [ currentException ] };


// If showJavaStackTrace is enabled, parse the Java stack trace and add it
// as a second (or more) entry in exception.values. This gives Sentry proper
// structured frames for the Java side instead of a raw text blob in "extra".
if ( arguments.showJavaStackTrace && len( arguments.exception.StackTrace ) ) {
Comment thread
jclausen marked this conversation as resolved.
Outdated
var javaExceptions = parseJavaStackTrace( arguments.exception.StackTrace );
for ( var je in javaExceptions ) {
arrayAppend( sentryException[ "exception" ].values, je );

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to also populate the tag context, by filtering the lines to .cfc and appending them to the context?

}
}

/*
* STACKTRACE INTERFACE
Expand Down Expand Up @@ -601,6 +600,222 @@ component accessors=true singleton {
);
}

/**
* Parse a raw Java stack trace string into Sentry exception values.
*
* Handles the standard Java stack trace format including:
* - Exception class name and message on the first line(s)
* - "at package.Class.method(File.java:line)" frames
* - "at package.Class.method(Native Method)" frames
* - "... N more" truncated frame indicators
* - "Caused by:" nested exception chains
*
* Returns an array of Sentry exception value structs, each with:
* - type: The Java exception class name
* - value: The exception message
* - stacktrace: { frames: [...] } with parsed frame objects
*/
private array function parseJavaStackTrace( required string stackTrace ){
var result = [];
// Strip \r to handle Windows-style line endings consistently
var cleaned = reReplace( arguments.stackTrace, "\\r", "", "All" );
var lines = listToArray( cleaned, chr( 10 ) );
var curType = "";
var curValue = "";
var curFrames = [];
var inException = false;

for ( var line in lines ) {
// Skip blank lines
if ( !len( trim( line ) ) ) {
continue;
}

var trimmedLine = trim( line );

// "... N more" — skip, these are duplicated frames
if ( left( trimmedLine, 3 ) == "..." && reFind( "^\\.\\.\\.\\s+\\d+\\s+more", trimmedLine ) ) {
continue;
}

// "Caused by: ..." — save current exception, start a new one
if ( left( trimmedLine, 10 ) == "Caused by:" ) {
Comment thread
jclausen marked this conversation as resolved.
Outdated
// Flush previous exception
if ( len( curType ) ) {
arrayAppend( result, _buildJavaExceptionValue( curType, curValue, curFrames ) );
}
// Parse: "Caused by: java.lang.Exception: message"
var afterPrefix = trim( mid( trimmedLine, 11 ) ); // skip "Caused by:"
var colonPos = find( ":", afterPrefix );
if ( colonPos > 1 ) {
curType = trim( mid( afterPrefix, 1, colonPos - 1 ) );
curValue = trim( mid( afterPrefix, colonPos + 1 ) );
} else {
curType = afterPrefix;
curValue = "";
}
curFrames = [];
inException = true;
continue;
}

// "Suppressed: ..." — same handling as Caused by (Java 7+)
if ( left( trimmedLine, 11 ) == "Suppressed:" ) {
// Flush previous exception
if ( len( curType ) ) {
arrayAppend( result, _buildJavaExceptionValue( curType, curValue, curFrames ) );
}
// Parse: "Suppressed: java.lang.Exception: message"
var afterSuppressed = trim( mid( trimmedLine, 12 ) ); // skip "Suppressed:"
var colonPos2 = find( ":", afterSuppressed );
if ( colonPos2 > 1 ) {
curType = trim( mid( afterSuppressed, 1, colonPos2 - 1 ) );
curValue = trim( mid( afterSuppressed, colonPos2 + 1 ) );
} else {
curType = afterSuppressed;
curValue = "";
}
curFrames = [];
inException = true;
continue;
}

// "at ..." frame line
if ( left( trimmedLine, 3 ) == "at " ) {
inException = true;
// Parse: "at com.example.Class.method(File.java:42)"
// or: "at com.example.Class.method(Native Method)"
var afterAt = mid( trimmedLine, 4 ); // skip "at "
var openParen = find( "(", afterAt );
var closeParen = find( ")", afterAt );

if ( openParen > 1 && closeParen > openParen ) {
var qualifiedName = mid( afterAt, 1, openParen - 1 );
var parenContent = mid(
afterAt,
openParen + 1,
closeParen - openParen - 1
);

// Split qualified name on last dot: "com.example.Class.method" → class + method
var parts = listToArray( qualifiedName, "." );
var atMethod = parts[ parts.len() ];
parts.deleteAt( parts.len() );
var atClass = arrayToList( parts, "." );

// Parse paren content: "File.java:42" or "Native Method"
var colonInParen = find( ":", parenContent );
if ( colonInParen > 1 ) {
var atFile = mid( parenContent, 1, colonInParen - 1 );
var atLine = val( mid( parenContent, colonInParen + 1 ) );
arrayAppend( curFrames, _buildJavaFrame( atClass, atMethod, atFile, atLine ) );
} else {
// Native Method, Unknown Source, etc.
arrayAppend( curFrames, _buildJavaFrame( atClass, atMethod, "", 0 ) );
}
}
continue;
}

// If we haven't hit any "at" lines yet, this is part of the exception header
if ( !inException ) {
var colonPos3 = find( ":", trimmedLine );
if ( colonPos3 > 1 ) {
// Check if it looks like an exception class name (no spaces before colon)
var beforeColon = mid( trimmedLine, 1, colonPos3 - 1 );
if ( !find( " ", beforeColon ) ) {
curType = beforeColon;
curValue = trim( mid( trimmedLine, colonPos3 + 1 ) );
} else {
// Space before colon — probably a continuation of the message
curValue = curValue & " " & trimmedLine;
}
} else if ( !len( curType ) ) {
// First line might just be the exception class
curType = trimmedLine;
} else {
// Continuation of the message
curValue = curValue & " " & trimmedLine;
}
}
}

// Flush the last exception
if ( len( curType ) ) {
arrayAppend( result, _buildJavaExceptionValue( curType, curValue, curFrames ) );
}

return result;
}

/**
* Build a single Sentry exception value struct for a Java exception.
*/
private struct function _buildJavaExceptionValue(
required string type,
required string value,
required array frames
){
return {
"type" : arguments.type,
"value" : arguments.value,
"stacktrace" : { "frames" : arguments.frames }
};
}

/**
* Build a single Sentry stacktrace frame from a Java "at" line.
*
* @className Fully qualified class name (e.g. "com.example.MyClass")
* @method Method name (e.g. "myMethod")
* @fileName Source file name (e.g. "MyClass.java"), may be empty
* @lineNumber Line number, 0 if unknown
*/
private struct function _buildJavaFrame(
required string className,
required string method,
required string fileName,
required numeric lineNumber
){
var frame = {
"function" : arguments.className & "." & arguments.method,
"filename" : arguments.fileName,
"lineno" : arguments.lineNumber,
"abs_path" : arguments.className,
"in_app" : false,
"context_line" : "",
"pre_context" : [],
"post_context" : []
};

// Heuristic: if the class doesn't start with common framework prefixes,
// it's probably application code
var frameworkPrefixes = [
"java.",
"javax.",
"jakarta.",
"sun.",
"com.sun.",
"org.apache.",
"org.springframework.",
"org.hibernate.",
"lucee.",
"boxlang."
];
var isFramework = false;
for ( var prefix in frameworkPrefixes ) {
if ( left( arguments.className, len( prefix ) ) == prefix ) {
isFramework = true;
break;
}
}
if ( !isFramework ) {
frame[ "in_app" ] = true;
}

return frame;
}

// recursivley replace any CFC instances with structs
function structifyObject( o, name = "" ){
var result = {};
Expand All @@ -611,7 +826,9 @@ component accessors=true singleton {
return structReduce(
o,
function( acc, k, v ){
if ( !isCustomFunction( v ) ) {
if ( isNull( arguments.v ) ) {
acc[ k ] = javacast( "null", 0 );
} else if ( !isCustomFunction( v ) ) {
if ( isObject( v ) ) {
acc[ k ] = structifyObject( v, getMetadata( v ).name );
} else if ( isStruct( v ) ) {
Expand Down
Loading