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
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,7 @@ logs/**
# don't put token
test-harness/config/token.cfm
test-harness/.env
/modules/
/modules/
# Local server configs (test artifacts)
server-sentry-*.json
test-harness/server-sentry-*.json
244 changes: 229 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,18 @@ component accessors=true singleton {
"type" : arguments.exception.type & " Error",
"stacktrace" : { "frames" : [] }
};

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


// If showJavaStackTrace is enabled AND there's no tagContext, parse the
// Java stack trace and add it as a second (or more) entry in exception.values.
// When tagContext is available, the CFML frames are sufficient — no need
// for the overhead of parsing the raw Java stack trace.
if ( arguments.showJavaStackTrace && !tagContext.len() && len( arguments.exception.StackTrace ) ) {
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 +601,218 @@ 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: ..." or "Suppressed: ..." — save current exception, start a new one
if ( left( trimmedLine, 10 ) == "Caused by:" || left( trimmedLine, 11 ) == "Suppressed:" ) {
// Flush previous exception
if ( len( curType ) ) {
arrayAppend( result, _buildJavaExceptionValue( curType, curValue, curFrames ) );
}
var prefixLen = ( left( trimmedLine, 10 ) == "Caused by:" ) ? 10 : 11;
var parsed = _parseExceptionPrefix( trimmedLine, prefixLen );
curType = parsed.type;
curValue = parsed.value;
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 = ( len( trimmedLine ) > 3 ) ? mid( trimmedLine, 4, len( trimmedLine ) ) : "";
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, len( parenContent ) ) );
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 = ( colonPos3 < len( trimmedLine ) )
? trim( mid( trimmedLine, colonPos3 + 1, len( trimmedLine ) ) )
: "";
} 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;
}

/**
* Parse a "Caused by:" or "Suppressed:" exception prefix line.
* Returns { type, value }.
*/
private struct function _parseExceptionPrefix(
required string line,
required numeric prefixLen
){
var afterPrefix = ( len( arguments.line ) > arguments.prefixLen )
? trim( mid( arguments.line, arguments.prefixLen + 1, len( arguments.line ) ) )
: "";
var colonPos = find( ":", afterPrefix );
if ( colonPos > 1 ) {
return {
"type" : trim( mid( afterPrefix, 1, colonPos - 1 ) ),
"value" : trim( mid( afterPrefix, colonPos + 1, len( afterPrefix ) ) )
};
}
return { "type" : afterPrefix, "value" : "" };
}

/**
* 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 +823,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
Loading