From 05346d9800edde0c3a6b3f9acaae54bf29be9748 Mon Sep 17 00:00:00 2001 From: "Karlin [bot]" <3655094+karlin[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 09:36:51 -0700 Subject: [PATCH 01/11] feat: send Java stack traces as structured exception values in Sentry events When showJavaStackTrace=true, the Java stack trace is now parsed into structured Sentry exception values with proper frames (function, filename, lineno, in_app) instead of being dumped as a raw text blob in 'extra'. This gives Sentry proper structured frames for the Java side of the call stack, enabling: - Frame-by-frame navigation in the Sentry UI - Proper exception chaining via 'Caused by:' parsing - Correct grouping and deduplication - in_app heuristics for application vs framework frames The parseJavaStackTrace() method handles: - Standard 'at package.Class.method(File.java:line)' frames - 'Native Method' and 'Unknown Source' frames (no line number) - 'Caused by:' nested exception chains - '... N more' truncated frame indicators Also includes: - Null-safety fix for structifyObject() (from inleague-api patch) - Deprecation of removeTabsOnJavaStackTrace parameter - 7 new test cases covering parsing, in_app detection, Caused by chains, empty TagContext fallback, and edge cases --- models/SentryService.cfc | 205 ++++++++++++++++++++-- test-harness/tests/specs/SentryTests.cfc | 214 +++++++++++++++++++++++ 2 files changed, 404 insertions(+), 15 deletions(-) diff --git a/models/SentryService.cfc b/models/SentryService.cfc index 0ce6f4f..0b14fa0 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,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 ) ) { + var javaExceptions = parseJavaStackTrace( arguments.exception.StackTrace ); + for ( var je in javaExceptions ) { + arrayAppend( sentryException[ "exception" ].values, je ); + } + } /* * STACKTRACE INTERFACE @@ -601,6 +600,180 @@ 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 = []; + var lines = listToArray( arguments.stackTrace, chr( 10 ) ); + var curType = ""; + var curValue = ""; + var curFrames = []; + var inException = false; + + // Regex for "at com.example.Class.method(File.java:42)" + // Note: (.+) for class is GREEDY to match the full qualified name up to the last dot before ( + var atPattern = "^\\s*at\\s+(.+)\\.(.+?)\\((.+?):(\\d+)\\)$"; + // Regex for "at com.example.Class.method(Native Method)" or "(Unknown Source)" + var atNoLinePat = "^\\s*at\\s+(.+)\\.(.+?)\\((.+?)\\)$"; + // Regex for "Caused by: java.lang.Exception: message" (message is optional) + var causedByPat = "^\\s*Caused by:\\s+(.+?)(?:\\s*:\\s*(.*))?$"; + // Regex for initial exception line "java.lang.Exception: message" + var exceptionPat = "^(.+?):\\s*(.*)$"; + // Regex for "... N more" lines + var morePat = "^\\s*\\.\\.\\.\\s+\\d+\\s+more\\s*$"; + + for ( var line in lines ) { + // Skip blank lines + if ( !len( trim( line ) ) ) { + continue; + } + + // "... N more" — skip, these are duplicated frames + if ( reFind( morePat, line ) ) { + continue; + } + + // "Caused by: ..." — save current exception, start a new one + var causedByMatch = reFind( causedByPat, line, 1, true ); + if ( causedByMatch.len[ 1 ] ) { + // Flush previous exception + if ( len( curType ) ) { + arrayAppend( + result, + _buildJavaExceptionValue( curType, curValue, curFrames ) + ); + } + curType = trim( mid( line, causedByMatch.pos[ 2 ], causedByMatch.len[ 2 ] ) ); + curValue = trim( mid( line, causedByMatch.pos[ 3 ], causedByMatch.len[ 3 ] ) ); + curFrames = []; + inException = true; + continue; + } + + // "at ..." frame line + var atMatch = reFind( atPattern, line, 1, true ); + if ( atMatch.len[ 1 ] ) { + inException = true; + var atClass = mid( line, atMatch.pos[ 2 ], atMatch.len[ 2 ] ); + var atMethod = mid( line, atMatch.pos[ 3 ], atMatch.len[ 3 ] ); + var atFile = mid( line, atMatch.pos[ 4 ], atMatch.len[ 4 ] ); + var atLine = val( mid( line, atMatch.pos[ 5 ], atMatch.len[ 5 ] ) ); + + arrayAppend( + curFrames, + _buildJavaFrame( atClass, atMethod, atFile, atLine ) + ); + continue; + } + + // "at ..." frame without line number (Native Method, Unknown Source) + var atNoLineMatch = reFind( atNoLinePat, line, 1, true ); + if ( atNoLineMatch.len[ 1 ] ) { + inException = true; + var atClass2 = mid( line, atNoLineMatch.pos[ 2 ], atNoLineMatch.len[ 2 ] ); + var atMethod2 = mid( line, atNoLineMatch.pos[ 3 ], atNoLineMatch.len[ 3 ] ); + + arrayAppend( + curFrames, + _buildJavaFrame( atClass2, atMethod2, "", 0 ) + ); + continue; + } + + // If we haven't hit any "at" lines yet, this is part of the exception header + if ( !inException ) { + var exMatch = reFind( exceptionPat, line, 1, true ); + if ( exMatch.len[ 1 ] ) { + curType = trim( mid( line, exMatch.pos[ 2 ], exMatch.len[ 2 ] ) ); + curValue = trim( mid( line, exMatch.pos[ 3 ], exMatch.len[ 3 ] ) ); + } else if ( !len( curType ) ) { + // First line might just be the exception class + curType = trim( line ); + } else { + // Continuation of the message + curValue &= " " & trim( line ); + } + } + } + + // 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 + if ( + !reFindNoCase( + "^(java\\.|javax\\.|sun\\.|com\\.sun\\.|org\\.apache\\.|org\\.springframework\\.|org\\.hibernate\\.|lucee\\.|boxlang\\.)", + arguments.className + ) + ) { + frame[ "in_app" ] = true; + } + + return frame; + } + // recursivley replace any CFC instances with structs function structifyObject( o, name = "" ){ var result = {}; @@ -611,7 +784,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..c5974ed 100644 --- a/test-harness/tests/specs/SentryTests.cfc +++ b/test-harness/tests/specs/SentryTests.cfc @@ -146,6 +146,220 @@ 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" ); + } ); } ); } From a9d0ccec200de00f9f06a6eacac7c7ca48bd3639 Mon Sep 17 00:00:00 2001 From: "Karlin [bot]" <3655094+karlin[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 09:39:33 -0700 Subject: [PATCH 02/11] fix: address review findings for Java stack trace parsing - Add \r stripping before splitting by \n (regression from old code) - Add jakarta. to in_app exclusion list (Jakarta EE classes) - Add Suppressed: exception handling (Java 7+ suppressed exceptions) - These fix medium-priority issues from code review --- models/SentryService.cfc | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/models/SentryService.cfc b/models/SentryService.cfc index 0b14fa0..eafb4a4 100644 --- a/models/SentryService.cfc +++ b/models/SentryService.cfc @@ -617,7 +617,9 @@ component accessors=true singleton { */ private array function parseJavaStackTrace( required string stackTrace ){ var result = []; - var lines = listToArray( arguments.stackTrace, chr( 10 ) ); + // 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 = []; @@ -630,6 +632,8 @@ component accessors=true singleton { var atNoLinePat = "^\\s*at\\s+(.+)\\.(.+?)\\((.+?)\\)$"; // Regex for "Caused by: java.lang.Exception: message" (message is optional) var causedByPat = "^\\s*Caused by:\\s+(.+?)(?:\\s*:\\s*(.*))?$"; + // Regex for "Suppressed: java.lang.Exception: message" (Java 7+) + var suppressedPat = "^\\s*Suppressed:\\s+(.+?)(?:\\s*:\\s*(.*))?$"; // Regex for initial exception line "java.lang.Exception: message" var exceptionPat = "^(.+?):\\s*(.*)$"; // Regex for "... N more" lines @@ -663,6 +667,23 @@ component accessors=true singleton { continue; } + // "Suppressed: ..." — same handling as Caused by (Java 7+) + var suppressedMatch = reFind( suppressedPat, line, 1, true ); + if ( suppressedMatch.len[ 1 ] ) { + // Flush previous exception + if ( len( curType ) ) { + arrayAppend( + result, + _buildJavaExceptionValue( curType, curValue, curFrames ) + ); + } + curType = trim( mid( line, suppressedMatch.pos[ 2 ], suppressedMatch.len[ 2 ] ) ); + curValue = trim( mid( line, suppressedMatch.pos[ 3 ], suppressedMatch.len[ 3 ] ) ); + curFrames = []; + inException = true; + continue; + } + // "at ..." frame line var atMatch = reFind( atPattern, line, 1, true ); if ( atMatch.len[ 1 ] ) { @@ -764,7 +785,7 @@ component accessors=true singleton { // it's probably application code if ( !reFindNoCase( - "^(java\\.|javax\\.|sun\\.|com\\.sun\\.|org\\.apache\\.|org\\.springframework\\.|org\\.hibernate\\.|lucee\\.|boxlang\\.)", + "^(java\\.|javax\\.|jakarta\\.|sun\\.|com\\.sun\\.|org\\.apache\\.|org\\.springframework\\.|org\\.hibernate\\.|lucee\\.|boxlang\\.)", arguments.className ) ) { From 57274951a94804c0c2f9aed8ade433ac6d356ba3 Mon Sep 17 00:00:00 2001 From: "Karlin [bot]" <3655094+karlin[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 09:40:29 -0700 Subject: [PATCH 03/11] test: add Suppressed exception parsing test --- test-harness/tests/specs/SentryTests.cfc | 32 ++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/test-harness/tests/specs/SentryTests.cfc b/test-harness/tests/specs/SentryTests.cfc index c5974ed..1b102f1 100644 --- a/test-harness/tests/specs/SentryTests.cfc +++ b/test-harness/tests/specs/SentryTests.cfc @@ -360,6 +360,38 @@ component extends="coldbox.system.testing.BaseTestCase" appMapping="/root" { 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 ); + } ); } ); } From 3ca293d09c89482bab8659a54262557b842e7904 Mon Sep 17 00:00:00 2001 From: "Karlin [bot]" <3655094+karlin[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 10:31:04 -0700 Subject: [PATCH 04/11] fix: rewrite Java stack trace parser to use string operations instead of regex BoxLang's regex engine doesn't handle non-greedy quantifiers and complex patterns reliably. Rewrote parseJavaStackTrace() to use find(), mid(), left() and simple string operations for parsing. This fixes all 5 test failures: - Exception type was including the message (regex not splitting on colon) - Frames array was empty (at-pattern regex not matching) - Caused by chains only producing 2 exceptions instead of 3 - Suppressed exceptions not being parsed Also fixes the upstream test failures caused by BoxLang incompatibility with the old regex-based approach. --- models/SentryService.cfc | 132 +++++++++++++++----------- test-harness/server-sentry-tests.json | 11 +++ 2 files changed, 90 insertions(+), 53 deletions(-) create mode 100644 test-harness/server-sentry-tests.json diff --git a/models/SentryService.cfc b/models/SentryService.cfc index eafb4a4..4aafb11 100644 --- a/models/SentryService.cfc +++ b/models/SentryService.cfc @@ -625,34 +625,21 @@ component accessors=true singleton { var curFrames = []; var inException = false; - // Regex for "at com.example.Class.method(File.java:42)" - // Note: (.+) for class is GREEDY to match the full qualified name up to the last dot before ( - var atPattern = "^\\s*at\\s+(.+)\\.(.+?)\\((.+?):(\\d+)\\)$"; - // Regex for "at com.example.Class.method(Native Method)" or "(Unknown Source)" - var atNoLinePat = "^\\s*at\\s+(.+)\\.(.+?)\\((.+?)\\)$"; - // Regex for "Caused by: java.lang.Exception: message" (message is optional) - var causedByPat = "^\\s*Caused by:\\s+(.+?)(?:\\s*:\\s*(.*))?$"; - // Regex for "Suppressed: java.lang.Exception: message" (Java 7+) - var suppressedPat = "^\\s*Suppressed:\\s+(.+?)(?:\\s*:\\s*(.*))?$"; - // Regex for initial exception line "java.lang.Exception: message" - var exceptionPat = "^(.+?):\\s*(.*)$"; - // Regex for "... N more" lines - var morePat = "^\\s*\\.\\.\\.\\s+\\d+\\s+more\\s*$"; - 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 ( reFind( morePat, line ) ) { + if ( left( trimmedLine, 3 ) == "..." && reFind( "^\\.\\.\\.\\s+\\d+\\s+more", trimmedLine ) ) { continue; } // "Caused by: ..." — save current exception, start a new one - var causedByMatch = reFind( causedByPat, line, 1, true ); - if ( causedByMatch.len[ 1 ] ) { + if ( left( trimmedLine, 9 ) == "Caused by" ) { // Flush previous exception if ( len( curType ) ) { arrayAppend( @@ -660,16 +647,23 @@ component accessors=true singleton { _buildJavaExceptionValue( curType, curValue, curFrames ) ); } - curType = trim( mid( line, causedByMatch.pos[ 2 ], causedByMatch.len[ 2 ] ) ); - curValue = trim( mid( line, causedByMatch.pos[ 3 ], causedByMatch.len[ 3 ] ) ); + // 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+) - var suppressedMatch = reFind( suppressedPat, line, 1, true ); - if ( suppressedMatch.len[ 1 ] ) { + if ( left( trimmedLine, 10 ) == "Suppressed" ) { // Flush previous exception if ( len( curType ) ) { arrayAppend( @@ -677,55 +671,87 @@ component accessors=true singleton { _buildJavaExceptionValue( curType, curValue, curFrames ) ); } - curType = trim( mid( line, suppressedMatch.pos[ 2 ], suppressedMatch.len[ 2 ] ) ); - curValue = trim( mid( line, suppressedMatch.pos[ 3 ], suppressedMatch.len[ 3 ] ) ); + // 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 - var atMatch = reFind( atPattern, line, 1, true ); - if ( atMatch.len[ 1 ] ) { + if ( left( trimmedLine, 3 ) == "at " ) { inException = true; - var atClass = mid( line, atMatch.pos[ 2 ], atMatch.len[ 2 ] ); - var atMethod = mid( line, atMatch.pos[ 3 ], atMatch.len[ 3 ] ); - var atFile = mid( line, atMatch.pos[ 4 ], atMatch.len[ 4 ] ); - var atLine = val( mid( line, atMatch.pos[ 5 ], atMatch.len[ 5 ] ) ); - - arrayAppend( - curFrames, - _buildJavaFrame( atClass, atMethod, atFile, atLine ) - ); - continue; - } - - // "at ..." frame without line number (Native Method, Unknown Source) - var atNoLineMatch = reFind( atNoLinePat, line, 1, true ); - if ( atNoLineMatch.len[ 1 ] ) { - inException = true; - var atClass2 = mid( line, atNoLineMatch.pos[ 2 ], atNoLineMatch.len[ 2 ] ); - var atMethod2 = mid( line, atNoLineMatch.pos[ 3 ], atNoLineMatch.len[ 3 ] ); + // 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 lastDot = 0; + for ( var p = len( qualifiedName ); p >= 1; p-- ) { + if ( mid( qualifiedName, p, 1 ) == "." ) { + lastDot = p; + break; + } + } - arrayAppend( - curFrames, - _buildJavaFrame( atClass2, atMethod2, "", 0 ) - ); + if ( lastDot > 1 ) { + var atClass = mid( qualifiedName, 1, lastDot - 1 ); + var atMethod = mid( qualifiedName, lastDot + 1 ); + + // 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 exMatch = reFind( exceptionPat, line, 1, true ); - if ( exMatch.len[ 1 ] ) { - curType = trim( mid( line, exMatch.pos[ 2 ], exMatch.len[ 2 ] ) ); - curValue = trim( mid( line, exMatch.pos[ 3 ], exMatch.len[ 3 ] ) ); + 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 = trim( line ); + curType = trimmedLine; } else { // Continuation of the message - curValue &= " " & trim( line ); + curValue = curValue & " " & trimmedLine; } } } diff --git a/test-harness/server-sentry-tests.json b/test-harness/server-sentry-tests.json new file mode 100644 index 0000000..82cb9d2 --- /dev/null +++ b/test-harness/server-sentry-tests.json @@ -0,0 +1,11 @@ +{ + "app":{ + "cfengine":"boxlang" + }, + "name":"sentry-tests", + "web":{ + "http":{ + "port":"8599" + } + } +} From b999857af56268c88cd9e5b3938f12f72cecc99c Mon Sep 17 00:00:00 2001 From: "Karlin [bot]" <3655094+karlin[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 10:32:56 -0700 Subject: [PATCH 05/11] fix: replace regex-based in_app heuristic with string prefix matching BoxLang's reFindNoCase doesn't handle complex regex patterns reliably. Replaced with simple left() prefix checks against a list of framework packages. More readable and guaranteed to work across engines. --- models/SentryService.cfc | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/models/SentryService.cfc b/models/SentryService.cfc index 4aafb11..ad3d34c 100644 --- a/models/SentryService.cfc +++ b/models/SentryService.cfc @@ -809,12 +809,19 @@ component accessors=true singleton { // Heuristic: if the class doesn't start with common framework prefixes, // it's probably application code - if ( - !reFindNoCase( - "^(java\\.|javax\\.|jakarta\\.|sun\\.|com\\.sun\\.|org\\.apache\\.|org\\.springframework\\.|org\\.hibernate\\.|lucee\\.|boxlang\\.)", - arguments.className - ) - ) { + 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; } From 527fa8dce2c6d6c123b2124f4d90cad2ac22c5f0 Mon Sep 17 00:00:00 2001 From: "Karlin [bot]" <3655094+karlin[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 10:42:58 -0700 Subject: [PATCH 06/11] fix: make upstream tests cross-engine compatible 1. 'can log Java exception': Use null-safe operator for e.message which is null when catching Java exceptions on both BoxLang and Lucee 2. 'can log exception with no tagContext': Replace 'for (var key in e)' iteration with duplicate()+structDelete() which works on all engines. BoxLang exceptions don't support key iteration like Lucee/ACF. --- test-harness/tests/specs/SentryTests.cfc | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/test-harness/tests/specs/SentryTests.cfc b/test-harness/tests/specs/SentryTests.cfc index 1b102f1..31adf36 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,8 @@ 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 ]; - } - } + var newE = duplicate( e ); + structDelete( newE, "TagContext" ); getLogbox().getRootLogger().error( "Missing tag Context", newE ); } } ); From 48973bfc7fa2f62b81fa5b00e796c9fa11c9af75 Mon Sep 17 00:00:00 2001 From: "Karlin [bot]" <3655094+karlin[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:03:42 -0700 Subject: [PATCH 07/11] style: address review nits from PR #1 1. Make prefix checks include the colon: 'Caused by:' and 'Suppressed:' instead of 'Caused by' and 'Suppressed' 2. Simplify last-dot iteration with listToArray/arrayToList instead of manual character loop 3. Fix indentation after removing the if(lastDot>1) block --- models/SentryService.cfc | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/models/SentryService.cfc b/models/SentryService.cfc index ad3d34c..f106a79 100644 --- a/models/SentryService.cfc +++ b/models/SentryService.cfc @@ -639,7 +639,7 @@ component accessors=true singleton { } // "Caused by: ..." — save current exception, start a new one - if ( left( trimmedLine, 9 ) == "Caused by" ) { + if ( left( trimmedLine, 10 ) == "Caused by:" ) { // Flush previous exception if ( len( curType ) ) { arrayAppend( @@ -663,7 +663,7 @@ component accessors=true singleton { } // "Suppressed: ..." — same handling as Caused by (Java 7+) - if ( left( trimmedLine, 10 ) == "Suppressed" ) { + if ( left( trimmedLine, 11 ) == "Suppressed:" ) { // Flush previous exception if ( len( curType ) ) { arrayAppend( @@ -700,17 +700,10 @@ component accessors=true singleton { var parenContent = mid( afterAt, openParen + 1, closeParen - openParen - 1 ); // Split qualified name on last dot: "com.example.Class.method" → class + method - var lastDot = 0; - for ( var p = len( qualifiedName ); p >= 1; p-- ) { - if ( mid( qualifiedName, p, 1 ) == "." ) { - lastDot = p; - break; - } - } - - if ( lastDot > 1 ) { - var atClass = mid( qualifiedName, 1, lastDot - 1 ); - var atMethod = mid( qualifiedName, lastDot + 1 ); + 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 ); @@ -729,9 +722,8 @@ component accessors=true singleton { ); } } - } - continue; - } + continue; + } // If we haven't hit any "at" lines yet, this is part of the exception header if ( !inException ) { From 6302404a839ef47b199ec60b4397a4dadefcf381 Mon Sep 17 00:00:00 2001 From: "Karlin [bot]" <3655094+karlin[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:15:50 -0700 Subject: [PATCH 08/11] style: apply cfformat formatting to modified files --- models/SentryService.cfc | 100 +++++++++++------------ test-harness/tests/specs/SentryTests.cfc | 4 +- 2 files changed, 50 insertions(+), 54 deletions(-) diff --git a/models/SentryService.cfc b/models/SentryService.cfc index f106a79..1a0ade7 100644 --- a/models/SentryService.cfc +++ b/models/SentryService.cfc @@ -344,7 +344,7 @@ 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 When true, parses the Java stack trace and sends it as structured exception entries in exception.values with proper Sentry frames. + * @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 @@ -616,13 +616,13 @@ component accessors=true singleton { * - stacktrace: { frames: [...] } with parsed frame objects */ private array function parseJavaStackTrace( required string stackTrace ){ - var result = []; + 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 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 ) { @@ -642,14 +642,11 @@ component accessors=true singleton { if ( left( trimmedLine, 10 ) == "Caused by:" ) { // Flush previous exception if ( len( curType ) ) { - arrayAppend( - result, - _buildJavaExceptionValue( curType, curValue, curFrames ) - ); + 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 ); + var colonPos = find( ":", afterPrefix ); if ( colonPos > 1 ) { curType = trim( mid( afterPrefix, 1, colonPos - 1 ) ); curValue = trim( mid( afterPrefix, colonPos + 1 ) ); @@ -657,7 +654,7 @@ component accessors=true singleton { curType = afterPrefix; curValue = ""; } - curFrames = []; + curFrames = []; inException = true; continue; } @@ -666,14 +663,11 @@ component accessors=true singleton { if ( left( trimmedLine, 11 ) == "Suppressed:" ) { // Flush previous exception if ( len( curType ) ) { - arrayAppend( - result, - _buildJavaExceptionValue( curType, curValue, curFrames ) - ); + arrayAppend( result, _buildJavaExceptionValue( curType, curValue, curFrames ) ); } // Parse: "Suppressed: java.lang.Exception: message" var afterSuppressed = trim( mid( trimmedLine, 12 ) ); // skip "Suppressed:" - var colonPos2 = find( ":", afterSuppressed ); + var colonPos2 = find( ":", afterSuppressed ); if ( colonPos2 > 1 ) { curType = trim( mid( afterSuppressed, 1, colonPos2 - 1 ) ); curValue = trim( mid( afterSuppressed, colonPos2 + 1 ) ); @@ -681,49 +675,47 @@ component accessors=true singleton { curType = afterSuppressed; curValue = ""; } - curFrames = []; + curFrames = []; inException = true; continue; } // "at ..." frame line if ( left( trimmedLine, 3 ) == "at " ) { - inException = true; + 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 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 ); + 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 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; + // 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 ) { @@ -750,10 +742,7 @@ component accessors=true singleton { // Flush the last exception if ( len( curType ) ) { - arrayAppend( - result, - _buildJavaExceptionValue( curType, curValue, curFrames ) - ); + arrayAppend( result, _buildJavaExceptionValue( curType, curValue, curFrames ) ); } return result; @@ -802,9 +791,16 @@ component accessors=true singleton { // 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." + "java.", + "javax.", + "jakarta.", + "sun.", + "com.sun.", + "org.apache.", + "org.springframework.", + "org.hibernate.", + "lucee.", + "boxlang." ]; var isFramework = false; for ( var prefix in frameworkPrefixes ) { @@ -830,9 +826,9 @@ component accessors=true singleton { return structReduce( o, function( acc, k, v ){ - if ( isNull( arguments.v ) ) { - acc[ k ] = javacast( "null", 0 ); - } else 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 31adf36..a98a6a8 100644 --- a/test-harness/tests/specs/SentryTests.cfc +++ b/test-harness/tests/specs/SentryTests.cfc @@ -163,8 +163,8 @@ component extends="coldbox.system.testing.BaseTestCase" appMapping="/root" { service.captureException( exception = testException, showJavaStackTrace = true ); - var payload = deserializeJSON( service.$callLog( "post" ).post[ 1 ][ 4 ] ); - var excValues = payload.exception.values; + 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 ); From 978e8c0d25ed780dac2ba9bd4225e7426971bee0 Mon Sep 17 00:00:00 2001 From: "Karlin [bot]" <3655094+karlin[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:20:59 -0700 Subject: [PATCH 09/11] chore: remove local test server config that was accidentally committed --- test-harness/server-sentry-tests.json | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 test-harness/server-sentry-tests.json diff --git a/test-harness/server-sentry-tests.json b/test-harness/server-sentry-tests.json deleted file mode 100644 index 82cb9d2..0000000 --- a/test-harness/server-sentry-tests.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "app":{ - "cfengine":"boxlang" - }, - "name":"sentry-tests", - "web":{ - "http":{ - "port":"8599" - } - } -} From 0993ab3a750a1d8db70591c8d6308b613059128d Mon Sep 17 00:00:00 2001 From: "Karlin [bot]" <3655094+karlin[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:21:43 -0700 Subject: [PATCH 10/11] refactor: address Jon's review on upstream PR #45 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Gate Java stack trace parsing on TagContext being empty — no need for the overhead when CFML frames are already available 2. Extract duplicated 'Caused by:'/'Suppressed:' parsing into _parseExceptionPrefix() helper function 3. Add test verifying Java traces are skipped when TagContext is present 4. Add .gitignore entries for local server test configs --- .gitignore | 5 +- models/SentryService.cfc | 71 +++++++++++------------- test-harness/tests/specs/SentryTests.cfc | 25 +++++++++ 3 files changed, 61 insertions(+), 40 deletions(-) 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 1a0ade7..07b6d7c 100644 --- a/models/SentryService.cfc +++ b/models/SentryService.cfc @@ -501,10 +501,11 @@ component accessors=true singleton { }; 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 ) ) { + // 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 ); @@ -638,45 +639,18 @@ component accessors=true singleton { continue; } - // "Caused by: ..." — save current exception, start a new one - if ( left( trimmedLine, 10 ) == "Caused by:" ) { + // "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 ) ); } - // 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; + var prefixLen = ( left( trimmedLine, 10 ) == "Caused by:" ) ? 10 : 11; + var parsed = _parseExceptionPrefix( trimmedLine, prefixLen ); + curType = parsed.type; + curValue = parsed.value; + curFrames = []; + inException = true; continue; } @@ -748,6 +722,25 @@ component accessors=true singleton { 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 = trim( mid( arguments.line, arguments.prefixLen + 1 ) ); + var colonPos = find( ":", afterPrefix ); + if ( colonPos > 1 ) { + return { + "type" : trim( mid( afterPrefix, 1, colonPos - 1 ) ), + "value" : trim( mid( afterPrefix, colonPos + 1 ) ) + }; + } + return { "type" : afterPrefix, "value" : "" }; + } + /** * Build a single Sentry exception value struct for a Java exception. */ diff --git a/test-harness/tests/specs/SentryTests.cfc b/test-harness/tests/specs/SentryTests.cfc index a98a6a8..f1caf10 100644 --- a/test-harness/tests/specs/SentryTests.cfc +++ b/test-harness/tests/specs/SentryTests.cfc @@ -388,6 +388,31 @@ component extends="coldbox.system.testing.BaseTestCase" appMapping="/root" { 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" ); + } ); } ); } From 39a7ebd5d9132e2da4d0192e1c050029a6067e06 Mon Sep 17 00:00:00 2001 From: "Karlin [bot]" <3655094+karlin[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:33:25 -0700 Subject: [PATCH 11/11] fix: Adobe CF compatibility for mid() and exception handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Adobe CF requires 3 parameters for mid() — the 2-parameter form (mid(str, start)) is not supported. Added length parameter to all mid() calls in parseJavaStackTrace() and _parseExceptionPrefix(). 2. Fix 'can log exception with no tagContext' test — duplicate(e) fails on Adobe CF because exception objects are Java-backed. Build the struct manually with known keys instead. Both fixes are engine-agnostic and pass on BoxLang 1.15, Lucee 5, and Adobe CF 2021. --- models/SentryService.cfc | 14 +++++++++----- test-harness/tests/specs/SentryTests.cfc | 10 ++++++++-- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/models/SentryService.cfc b/models/SentryService.cfc index 07b6d7c..839b0d0 100644 --- a/models/SentryService.cfc +++ b/models/SentryService.cfc @@ -659,7 +659,7 @@ component accessors=true singleton { 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 afterAt = ( len( trimmedLine ) > 3 ) ? mid( trimmedLine, 4, len( trimmedLine ) ) : ""; var openParen = find( "(", afterAt ); var closeParen = find( ")", afterAt ); @@ -681,7 +681,7 @@ component accessors=true singleton { var colonInParen = find( ":", parenContent ); if ( colonInParen > 1 ) { var atFile = mid( parenContent, 1, colonInParen - 1 ); - var atLine = val( mid( parenContent, colonInParen + 1 ) ); + var atLine = val( mid( parenContent, colonInParen + 1, len( parenContent ) ) ); arrayAppend( curFrames, _buildJavaFrame( atClass, atMethod, atFile, atLine ) ); } else { // Native Method, Unknown Source, etc. @@ -699,7 +699,9 @@ component accessors=true singleton { var beforeColon = mid( trimmedLine, 1, colonPos3 - 1 ); if ( !find( " ", beforeColon ) ) { curType = beforeColon; - curValue = trim( mid( trimmedLine, colonPos3 + 1 ) ); + curValue = ( colonPos3 < len( trimmedLine ) ) + ? trim( mid( trimmedLine, colonPos3 + 1, len( trimmedLine ) ) ) + : ""; } else { // Space before colon — probably a continuation of the message curValue = curValue & " " & trimmedLine; @@ -730,12 +732,14 @@ component accessors=true singleton { required string line, required numeric prefixLen ){ - var afterPrefix = trim( mid( arguments.line, arguments.prefixLen + 1 ) ); + 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 ) ) + "value" : trim( mid( afterPrefix, colonPos + 1, len( afterPrefix ) ) ) }; } return { "type" : afterPrefix, "value" : "" }; diff --git a/test-harness/tests/specs/SentryTests.cfc b/test-harness/tests/specs/SentryTests.cfc index f1caf10..0dd4056 100644 --- a/test-harness/tests/specs/SentryTests.cfc +++ b/test-harness/tests/specs/SentryTests.cfc @@ -54,8 +54,14 @@ component extends="coldbox.system.testing.BaseTestCase" appMapping="/root" { try { throw( "Missing tag Context" ); } catch ( any e ) { - var newE = duplicate( e ); - structDelete( newE, "TagContext" ); + // 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 ); } } );