diff --git a/.gitignore b/.gitignore index 750db63..df56d36 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,7 @@ logs/** # don't put token test-harness/config/token.cfm test-harness/.env -/modules/ \ No newline at end of file +/modules/ +# Local server configs (test artifacts) +server-sentry-*.json +test-harness/server-sentry-*.json diff --git a/models/SentryService.cfc b/models/SentryService.cfc index 0ce6f4f..839b0d0 100644 --- a/models/SentryService.cfc +++ b/models/SentryService.cfc @@ -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 @@ -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; @@ -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 ); + } + } /* * STACKTRACE INTERFACE @@ -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 = {}; @@ -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 ) ) { diff --git a/test-harness/tests/specs/SentryTests.cfc b/test-harness/tests/specs/SentryTests.cfc index d78a660..0dd4056 100644 --- a/test-harness/tests/specs/SentryTests.cfc +++ b/test-harness/tests/specs/SentryTests.cfc @@ -46,7 +46,7 @@ component extends="coldbox.system.testing.BaseTestCase" appMapping="/root" { try { foo = createObject( "java", "java.io.File" ).init( getNull() ); } catch ( any e ) { - getLogbox().getRootLogger().error( e.message, e ); + getLogbox().getRootLogger().error( e.message ?: "Java exception", e ); } } ); @@ -54,12 +54,14 @@ component extends="coldbox.system.testing.BaseTestCase" appMapping="/root" { try { throw( "Missing tag Context" ); } catch ( any e ) { - var newE = {}; - for ( var key in e ) { - if ( key != "TagContext" ) { - newE[ key ] = e[ key ]; - } - } + // Build a struct manually — duplicate(e) fails on Adobe CF + // and for...in iteration fails on BoxLang + var newE = { + "message" : e.message ?: "", + "detail" : e.detail ?: "", + "type" : e.type ?: "", + "StackTrace" : e.StackTrace ?: "" + }; getLogbox().getRootLogger().error( "Missing tag Context", newE ); } } ); @@ -146,6 +148,277 @@ component extends="coldbox.system.testing.BaseTestCase" appMapping="/root" { var traceParent = service.$callLog( "post" ).post[ 1 ][ 5 ]; expect( traceParent ).toBe( testTraceParent ); } ); + + // ========== Java Stack Trace Parsing Tests ========== + + it( "can parse a simple Java stack trace into exception values", function(){ + var service = prepareMock( getSentry() ); + service.setEnabled( true ); + service.$( "post" ); + + var testException = { + "message" : "Something failed", + "detail" : "", + "type" : "application", + "TagContext" : [], + "StackTrace" : "java.lang.NullPointerException: null object reference + at com.example.MyClass.myMethod(MyClass.java:42) + at com.example.MyClass.otherMethod(MyClass.java:100) + at org.apache.catalina.core.StandardWrapper.invoke(StandardWrapper.java:500)" + }; + + service.captureException( exception = testException, showJavaStackTrace = true ); + + var payload = deserializeJSON( service.$callLog( "post" ).post[ 1 ][ 4 ] ); + var excValues = payload.exception.values; + + // Should have 2 entries: BoxLang exception + Java exception + expect( excValues.len() ).toBe( 2 ); + + // First entry is the BoxLang CFML exception + expect( excValues[ 1 ].type ).toBe( "application Error" ); + expect( excValues[ 1 ].stacktrace.frames.len() ).toBe( 0 ); + + // Second entry is the parsed Java exception + expect( excValues[ 2 ].type ).toBe( "java.lang.NullPointerException" ); + expect( excValues[ 2 ].value ).toBe( "null object reference" ); + expect( excValues[ 2 ].stacktrace.frames.len() ).toBe( 3 ); + + // Verify first frame parsing + var frame1 = excValues[ 2 ].stacktrace.frames[ 1 ]; + expect( frame1.function ).toBe( "com.example.MyClass.myMethod" ); + expect( frame1.filename ).toBe( "MyClass.java" ); + expect( frame1.lineno ).toBe( 42 ); + expect( frame1.abs_path ).toBe( "com.example.MyClass" ); + } ); + + it( "marks application frames as in_app and framework frames as not", function(){ + var service = prepareMock( getSentry() ); + service.setEnabled( true ); + service.$( "post" ); + + var testException = { + "message" : "Error", + "detail" : "", + "type" : "application", + "TagContext" : [], + "StackTrace" : "java.io.IOException: file not found + at com.example.service.FileHelper.read(FileHelper.java:55) + at org.apache.commons.io.IOUtils.toString(IOUtils.java:2000) + at java.io.FileInputStream.(FileInputStream.java:138)" + }; + + service.captureException( exception = testException, showJavaStackTrace = true ); + + var payload = deserializeJSON( service.$callLog( "post" ).post[ 1 ][ 4 ] ); + var excValues = payload.exception.values; + var frames = excValues[ 2 ].stacktrace.frames; + + // com.example = in_app + expect( frames[ 1 ].in_app ).toBe( true ); + // org.apache.commons = not in_app + expect( frames[ 2 ].in_app ).toBe( false ); + // java.io = not in_app + expect( frames[ 3 ].in_app ).toBe( false ); + } ); + + it( "parses Native Method frames without line numbers", function(){ + var service = prepareMock( getSentry() ); + service.setEnabled( true ); + service.$( "post" ); + + var testException = { + "message" : "Error", + "detail" : "", + "type" : "expression", + "TagContext" : [], + "StackTrace" : "java.lang.NullPointerException + at java.io.FileInputStream.open0(Native Method) + at java.io.FileInputStream.open(FileInputStream.java:195) + at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)" + }; + + service.captureException( exception = testException, showJavaStackTrace = true ); + + var payload = deserializeJSON( service.$callLog( "post" ).post[ 1 ][ 4 ] ); + var frames = payload.exception.values[ 2 ].stacktrace.frames; + + // Native Method frame — no line number + expect( frames[ 1 ].lineno ).toBe( 0 ); + expect( frames[ 1 ].function ).toBe( "java.io.FileInputStream.open0" ); + expect( frames[ 1 ].filename ).toBe( "" ); + + // Regular frame with line number + expect( frames[ 2 ].lineno ).toBe( 195 ); + expect( frames[ 2 ].filename ).toBe( "FileInputStream.java" ); + } ); + + it( "parses Caused by chains into multiple exception values", function(){ + var service = prepareMock( getSentry() ); + service.setEnabled( true ); + service.$( "post" ); + + var testException = { + "message" : "Wrapper error", + "detail" : "", + "type" : "application", + "TagContext" : [], + "StackTrace" : "java.lang.RuntimeException: something went wrong + at com.example.App.main(App.java:10) + Caused by: java.io.FileNotFoundException: /tmp/missing.txt + at java.io.FileInputStream.open0(Native Method) + at java.io.FileInputStream.(FileInputStream.java:138) + ... 3 more" + }; + + service.captureException( exception = testException, showJavaStackTrace = true ); + + var payload = deserializeJSON( service.$callLog( "post" ).post[ 1 ][ 4 ] ); + var excValues = payload.exception.values; + + // Should have 3 entries: BoxLang + RuntimeException + FileNotFoundException + expect( excValues.len() ).toBe( 3 ); + + // Second entry: RuntimeException + expect( excValues[ 2 ].type ).toBe( "java.lang.RuntimeException" ); + expect( excValues[ 2 ].value ).toBe( "something went wrong" ); + expect( excValues[ 2 ].stacktrace.frames.len() ).toBe( 1 ); + + // Third entry: FileNotFoundException + expect( excValues[ 3 ].type ).toBe( "java.io.FileNotFoundException" ); + expect( excValues[ 3 ].value ).toBe( "/tmp/missing.txt" ); + // "... 3 more" should be skipped, only 2 actual frames + expect( excValues[ 3 ].stacktrace.frames.len() ).toBe( 2 ); + } ); + + it( "does not add Java stack trace entries when showJavaStackTrace is false", function(){ + var service = prepareMock( getSentry() ); + service.setEnabled( true ); + service.$( "post" ); + + var testException = { + "message" : "Error", + "detail" : "", + "type" : "application", + "TagContext" : [ { "TEMPLATE" : "/test.cfm", "LINE" : 1 } ], + "StackTrace" : "java.lang.RuntimeException: boom + at com.example.App.main(App.java:10)" + }; + + service.captureException( exception = testException, showJavaStackTrace = false ); + + var payload = deserializeJSON( service.$callLog( "post" ).post[ 1 ][ 4 ] ); + var excValues = payload.exception.values; + + // Should only have 1 entry: BoxLang exception + expect( excValues.len() ).toBe( 1 ); + expect( excValues[ 1 ].type ).toBe( "application Error" ); + } ); + + it( "forces showJavaStackTrace when TagContext is empty", function(){ + var service = prepareMock( getSentry() ); + service.setEnabled( true ); + service.$( "post" ); + + var testException = { + "message" : "Error", + "detail" : "", + "type" : "application", + "TagContext" : [], + "StackTrace" : "java.lang.NullPointerException + at com.example.App.run(App.java:25)" + }; + + // Even with showJavaStackTrace defaulting to false, + // empty TagContext should force it on + service.captureException( exception = testException ); + + var payload = deserializeJSON( service.$callLog( "post" ).post[ 1 ][ 4 ] ); + var excValues = payload.exception.values; + + expect( excValues.len() ).toBe( 2 ); + expect( excValues[ 2 ].type ).toBe( "java.lang.NullPointerException" ); + } ); + + it( "handles exception messages with no colon separator", function(){ + var service = prepareMock( getSentry() ); + service.setEnabled( true ); + service.$( "post" ); + + var testException = { + "message" : "Error", + "detail" : "", + "type" : "application", + "TagContext" : [], + "StackTrace" : "NullPointerException + at com.example.App.run(App.java:25)" + }; + + service.captureException( exception = testException, showJavaStackTrace = true ); + + var payload = deserializeJSON( service.$callLog( "post" ).post[ 1 ][ 4 ] ); + var excValues = payload.exception.values; + + expect( excValues.len() ).toBe( 2 ); + expect( excValues[ 2 ].type ).toBe( "NullPointerException" ); + } ); + + it( "parses Suppressed exceptions into separate values", function(){ + var service = prepareMock( getSentry() ); + service.setEnabled( true ); + service.$( "post" ); + + var testException = { + "message" : "Error", + "detail" : "", + "type" : "application", + "TagContext" : [], + "StackTrace" : "java.io.IOException: original error + at com.example.App.main(App.java:10) + Suppressed: java.io.IOException: suppressed error + at com.example.App.helper(App.java:20) + ... 1 more" + }; + + service.captureException( exception = testException, showJavaStackTrace = true ); + + var payload = deserializeJSON( service.$callLog( "post" ).post[ 1 ][ 4 ] ); + var excValues = payload.exception.values; + + // BoxLang + original IOException + suppressed IOException + expect( excValues.len() ).toBe( 3 ); + expect( excValues[ 2 ].type ).toBe( "java.io.IOException" ); + expect( excValues[ 2 ].value ).toBe( "original error" ); + expect( excValues[ 2 ].stacktrace.frames.len() ).toBe( 1 ); + expect( excValues[ 3 ].type ).toBe( "java.io.IOException" ); + expect( excValues[ 3 ].value ).toBe( "suppressed error" ); + expect( excValues[ 3 ].stacktrace.frames.len() ).toBe( 1 ); + } ); + + it( "skips Java stack trace parsing when TagContext is available", function(){ + var service = prepareMock( getSentry() ); + service.setEnabled( true ); + service.$( "post" ); + + var testException = { + "message" : "Error", + "detail" : "", + "type" : "application", + "TagContext" : [ { "TEMPLATE" : "/test.cfm", "LINE" : 1 } ], + "StackTrace" : "java.lang.RuntimeException: boom + at com.example.App.main(App.java:10)" + }; + + // Even with showJavaStackTrace=true, TagContext is available + service.captureException( exception = testException, showJavaStackTrace = true ); + + var payload = deserializeJSON( service.$callLog( "post" ).post[ 1 ][ 4 ] ); + var excValues = payload.exception.values; + + // Should only have 1 entry — CFML frames are sufficient + expect( excValues.len() ).toBe( 1 ); + expect( excValues[ 1 ].type ).toBe( "application Error" ); + } ); } ); }