diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index a4ed296..9508e5d 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -20,7 +20,7 @@ jobs: # Format PR format_check: name: Checks Source Code Formatting - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 steps: - name: Checkout Repository uses: actions/checkout@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 17062c7..9327cb5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,7 +26,7 @@ jobs: ########################################################################################## build: name: Build & Publish - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 steps: - name: Checkout Repository uses: actions/checkout@v4 @@ -133,7 +133,7 @@ jobs: prep_next_release: name: Prep Next Release if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 needs: [ build ] steps: # Checkout development diff --git a/.github/workflows/snapshot.yml b/.github/workflows/snapshot.yml index 98cef2e..7b76e82 100644 --- a/.github/workflows/snapshot.yml +++ b/.github/workflows/snapshot.yml @@ -4,6 +4,7 @@ on: push: branches: - 'development' + workflow_dispatch: # Unique group name per workflow-branch/tag combo concurrency: @@ -23,7 +24,7 @@ jobs: ########################################################################################## format: name: Code Auto-Formatting - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index bbb8ed5..6ec41c8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,7 +10,7 @@ on: jobs: tests: name: Tests - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 env: DB_USER: root DB_PASSWORD: root @@ -35,6 +35,9 @@ jobs: - coldboxVersion: "be" cfengine: "adobe@2023" experimental: true + - coldboxVersion: "be" + cfengine: "boxlang@1" + experimental: true steps: - name: Checkout Repository uses: actions/checkout@v4 @@ -54,6 +57,11 @@ jobs: - name: Setup CommandBox CLI uses: Ortus-Solutions/setup-commandbox@v2.0.1 + - name: Update Commandbox Boxlang Module + if: ${{ matrix.cfengine == 'boxlang@1' }} + run: + box install --force commandbox-boxlang + # Not Needed in this module #- name: Setup Environment For Testing Process # run: | 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/ModuleConfig.cfc b/ModuleConfig.cfc index fb8a389..8e32d48 100644 --- a/ModuleConfig.cfc +++ b/ModuleConfig.cfc @@ -73,7 +73,9 @@ component { "platform" : "cfml", "logger" : ( !isNull( controller ) ? controller.getSetting( "appName" ) : "sentry" ), "userInfoUDF" : "", - "extraInfoUDFs" : {} + "extraInfoUDFs" : {}, // these two settings may be added but they + "showJavaStackTrace" : false, + "throwOnPostError" : false }; // Try to look up the release based on a box.json diff --git a/box.json b/box.json index ccd679e..e415806 100644 --- a/box.json +++ b/box.json @@ -1,7 +1,7 @@ { "name":"Sentry", "author":"Ortus Solutions ", - "version":"2.1.5", + "version":"2.1.6", "slug":"sentry", "type":"modules", "homepage":"https://github.com/coldbox-modules/sentry", diff --git a/changelog.md b/changelog.md index 8627281..f2daf70 100644 --- a/changelog.md +++ b/changelog.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.1.5] - 2024-12-25 + ### Fixed - [Issue #37](https://github.com/coldbox-modules/sentry/issues/37) - Fix var-scoping inside conditional ## [2.1.4] - 2024-12-24 @@ -49,12 +51,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Create first module version -[Unreleased]: https://github.com/coldbox-modules/sentry/compare/v2.1.4...HEAD +[Unreleased]: https://github.com/coldbox-modules/sentry/compare/v2.1.5...HEAD -[2.1.4]: https://github.com/coldbox-modules/sentry/compare/HEAD...v2.1.4 +[2.1.5]: https://github.com/coldbox-modules/sentry/compare/v2.1.4...v2.1.5 +[2.1.4]: https://github.com/coldbox-modules/sentry/compare/HEAD...v2.1.4 [2.1.3]: https://github.com/coldbox-modules/sentry/compare/v2.1.0...v2.1.3 - [2.1.0]: https://github.com/coldbox-modules/sentry/compare/57864cae5969ad38eee194db5a6b2798e91967b3...v2.1.0 diff --git a/models/SentryService.cfc b/models/SentryService.cfc index cf47fe7..11bf0fc 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 @@ -383,12 +383,8 @@ component accessors=true singleton { arguments.exception.message = arguments.exception.message ?: ""; var sentryExceptionExtra = {}; - var file = ""; - var fileArray = ""; - var currentTemplate = ""; var tagContext = arguments.exception.TagContext; var i = 1; - var st = ""; // If there's no tag context, include the stack trace instead if ( !tagContext.len() ) { @@ -422,16 +418,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 +495,23 @@ 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 ); + } + // Dynamically generate tagContext from CFML/BoxLang template + // references in the Java stack trace, so the frame-building loop + // below can populate source code context for these frames. + tagContext = extractCFMLTagContextFromStackTrace( arguments.exception.StackTrace ); + } /* * STACKTRACE INTERFACE @@ -523,26 +524,17 @@ component accessors=true singleton { var stacki = 0; for ( i = arrayLen( tagContext ); i > 0; i-- ) { stacki++; - var thisTCItem = tagContext[ i ]; - if ( compareNoCase( thisTCItem[ "TEMPLATE" ], currentTemplate ) ) { - fileArray = []; - if ( fileExists( thisTCItem[ "TEMPLATE" ] ) ) { - file = fileOpen( thisTCItem[ "TEMPLATE" ], "read" ); - while ( !fileIsEOF( file ) ) { - arrayAppend( fileArray, fileReadLine( file ) ); - } - fileClose( file ); - } - currentTemplate = thisTCItem[ "TEMPLATE" ]; - } + var thisTCItem = tagContext[ i ]; + var templatePath = normalizeSlashes( thisTCItem[ "TEMPLATE" ] ); + var sourceCtx = readSourceContext( templatePath, thisTCItem[ "LINE" ] ); var thisStackItem = { - "abs_path" : thisTCItem[ "TEMPLATE" ], - "filename" : normalizeSlashes( thisTCItem[ "TEMPLATE" ] ).replace( variables.settings.appRoot, "" ), + "abs_path" : templatePath, + "filename" : templatePath.replace( variables.settings.appRoot, "" ), "lineno" : thisTCItem[ "LINE" ], - "pre_context" : [], - "context_line" : "", - "post_context" : [] + "pre_context" : sourceCtx.pre_context, + "context_line" : sourceCtx.context_line, + "post_context" : sourceCtx.post_context }; // The name of the function being called @@ -551,54 +543,395 @@ component accessors=true singleton { thisStackItem[ "function" ] = functionName; } - // for source code rendering - var fileLen = arrayLen( fileArray ); - var errorLine = thisTCItem[ "LINE" ]; + currentException[ "stacktrace" ][ "frames" ][ stacki ] = thisStackItem; + } - if ( errorLine - 3 >= 1 && errorLine - 3 <= fileLen ) { - thisStackItem.pre_context[ 1 ] = fileArray[ errorLine - 3 ]; - } - if ( errorLine - 2 >= 1 && errorLine - 2 <= fileLen ) { - thisStackItem.pre_context[ 2 ] = fileArray[ errorLine - 2 ]; + capture( + captureStruct: sentryException, + path : arguments.path, + cgiVars : arguments.cgiVars, + useThread : arguments.useThread, + userInfo : arguments.userInfo + ); + } + + /** + * 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; } - if ( errorLine - 1 >= 1 && errorLine - 1 <= fileLen ) { - thisStackItem.pre_context[ 3 ] = fileArray[ errorLine - 1 ]; + + var trimmedLine = trim( line ); + + // "... N more" — skip, these are duplicated frames + if ( left( trimmedLine, 3 ) == "..." && reFind( "^\\.\\.\\.\\s+\\d+\\s+more", trimmedLine ) ) { + continue; } - if ( errorLine <= fileLen && fileLen > 0 && errorLine >= 1 ) { - thisStackItem[ "context_line" ] = fileArray[ errorLine ]; + // "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; } - if ( fileLen >= errorLine + 1 ) { - var errorLine1 = errorLine + 1; + // "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 ( errorLine1 != 0 ) { - thisStackItem.post_context[ 1 ] = fileArray[ errorLine1 ]; - } else if ( fileLen >= errorLine1 + 1 ) { - thisStackItem.post_context[ 1 ] = fileArray[ errorLine1 + 1 ]; + // 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; } } + } - if ( fileLen >= errorLine + 2 ) { - var errorLine2 = errorLine + 2; + // Flush the last exception + if ( len( curType ) ) { + arrayAppend( result, _buildJavaExceptionValue( curType, curValue, curFrames ) ); + } - if ( errorLine2 != 1 ) { - thisStackItem.post_context[ 2 ] = fileArray[ errorLine2 ]; - } else if ( fileLen >= errorLine2 + 1 ) { - thisStackItem.post_context[ 2 ] = fileArray[ errorLine2 + 1 ]; - } + 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; + } + + /** + * Read source context lines around an error line in a template file. + * Returns a struct with pre_context (3 lines before), context_line, + * and post_context (2 lines after). + * + * @templatePath Absolute path to the template file + * @errorLine The line number where the error occurred (1-based) + */ + private struct function readSourceContext( required string templatePath, required numeric errorLine ){ + var result = { + "pre_context" : [], + "context_line" : "", + "post_context" : [] + }; + + if ( !fileExists( arguments.templatePath ) ) { + return result; + } + + var fileArray = []; + var f = fileOpen( arguments.templatePath, "read" ); + while ( !fileIsEOF( f ) ) { + arrayAppend( fileArray, fileReadLine( f ) ); + } + fileClose( f ); + + var fileLen = arrayLen( fileArray ); + + // Pre-context: 3 lines before the error line + if ( errorLine - 3 >= 1 && errorLine - 3 <= fileLen ) { + result.pre_context[ 1 ] = fileArray[ errorLine - 3 ]; + } + if ( errorLine - 2 >= 1 && errorLine - 2 <= fileLen ) { + result.pre_context[ 2 ] = fileArray[ errorLine - 2 ]; + } + if ( errorLine - 1 >= 1 && errorLine - 1 <= fileLen ) { + result.pre_context[ 3 ] = fileArray[ errorLine - 1 ]; + } + + // Context line (the error line itself) + if ( errorLine <= fileLen && fileLen > 0 && errorLine >= 1 ) { + result.context_line = fileArray[ errorLine ]; + } + + // Post-context: 2 lines after the error line + if ( fileLen >= errorLine + 1 ) { + var errorLine1 = errorLine + 1; + if ( errorLine1 != 0 ) { + result.post_context[ 1 ] = fileArray[ errorLine1 ]; + } else if ( fileLen >= errorLine1 + 1 ) { + result.post_context[ 1 ] = fileArray[ errorLine1 + 1 ]; } + } - currentException[ "stacktrace" ][ "frames" ][ stacki ] = thisStackItem; + if ( fileLen >= errorLine + 2 ) { + var errorLine2 = errorLine + 2; + if ( errorLine2 != 1 ) { + result.post_context[ 2 ] = fileArray[ errorLine2 ]; + } else if ( fileLen >= errorLine2 + 1 ) { + result.post_context[ 2 ] = fileArray[ errorLine2 + 1 ]; + } } - capture( - captureStruct: sentryException, - path : arguments.path, - cgiVars : arguments.cgiVars, - useThread : arguments.useThread, - userInfo : arguments.userInfo - ); + return result; + } + + /** + * Extract CFML/BoxLang template references from a Java stack trace and + * synthesize TagContext entries so the frame-building loop can populate + * source code context for frames that originated in CFML/BoxLang code. + * + * Matches patterns like: + * - Lucee: at com_example_App_cfc$cf.call(/var/www/App.cfc:42) + * - BoxLang: at boxlang.runtime...(/path/to/Component.bx:15) + * + * Returns an array of structs with keys compatible with the engine-provided + * TagContext (TEMPLATE, LINE, Raw_Trace, type, column, id, codePrintPlain). + * Returns an empty array if no CFML/BoxLang template references are found. + * + * @stackTrace The raw Java stack trace string (typically exception.StackTrace) + */ + private array function extractCFMLTagContextFromStackTrace( required string stackTrace ){ + var result = []; + var cleaned = reReplace( arguments.stackTrace, "\\r", "", "All" ); + var lines = listToArray( cleaned, chr( 10 ) ); + var seenFrames = {}; + + // Regex: match a CFML/BoxLang template path followed by :lineNumber + // inside parentheses — e.g., (/path/to/file.cfm:42) or (C:\app\file.bx:15) + var templatePattern = "\(([^)]+\.(cfm|cfc|bx|bxs|bxm)):(\d+)\)"; + + for ( var line in lines ) { + var trimmedLine = trim( line ); + if ( !len( trimmedLine ) ) { + continue; + } + + // Skip lines that are clearly not CFML-related (e.g., pure Java frames) + // but don't prematurely skip — let the regex decide + var refMatches = reFindNoCase( templatePattern, trimmedLine, 1, true ); + + if ( refMatches.pos[ 1 ] > 0 ) { + var templatePath = mid( + trimmedLine, + refMatches.pos[ 2 ], + refMatches.len[ 2 ] + ); + var extension = mid( + trimmedLine, + refMatches.pos[ 3 ], + refMatches.len[ 3 ] + ); + var lineNumber = val( + mid( + trimmedLine, + refMatches.pos[ 4 ], + refMatches.len[ 4 ] + ) + ); + + // Normalize Windows backslash paths to forward slashes + templatePath = normalizeSlashes( templatePath ); + + // Skip invalid line numbers + if ( lineNumber <= 0 ) { + continue; + } + + // Deduplicate: skip if we've already seen this template + line combo + var dedupKey = templatePath & ":" & lineNumber; + if ( structKeyExists( seenFrames, dedupKey ) ) { + continue; + } + seenFrames[ dedupKey ] = true; + + var typeLabel = "cfml"; + if ( listFindNoCase( "bx,bxs,bxm", extension ) ) { + typeLabel = "boxlang"; + } + + arrayAppend( + result, + { + "TEMPLATE" : templatePath, + "LINE" : lineNumber, + "Raw_Trace" : trimmedLine, + "type" : typeLabel, + "column" : 0, + "id" : "??", + "codePrintPlain" : "" + } + ); + } + } + + return result; } // recursivley replace any CFC instances with structs @@ -611,7 +944,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 ) ) { @@ -647,7 +982,7 @@ component accessors=true singleton { var header = ""; var timeVars = getTimeVars(); var httpRequestData = getHTTPDataForRequest(); - var traceParent = httpRequestData.headers.traceParent ?: ""; + var traceParent = httpRequestData.headers.traceParent ?: ""; // Add global metadata arguments.captureStruct[ "event_id" ] = lCase( replace( createUUID(), "-", "", "all" ) ); @@ -718,7 +1053,9 @@ component accessors=true singleton { if ( listFindNoCase( "id,email,ip_address,username", key ) ) { key = lCase( key ); } - correctCasingUserInfo[ key ] = thisUserInfo[ key ]; + if ( !isNull( thisUserInfo[ key ] ) ) { + correctCasingUserInfo[ key ] = thisUserInfo[ key ]; + } } arguments.captureStruct[ "user" ] = correctCasingUserInfo; diff --git a/server-boxlang@1.json b/server-boxlang@1.json new file mode 100644 index 0000000..c43ef5f --- /dev/null +++ b/server-boxlang@1.json @@ -0,0 +1,29 @@ +{ + "app":{ + "cfengine":"boxlang@1", + "serverHomeDirectory":".engine/boxlang" + }, + "name":"sentry-boxlang@1", + "force":true, + "openBrowser":false, + "web":{ + "directoryBrowsing":true, + "http":{ + "port":"60299" + }, + "rewrites":{ + "enable":"true" + }, + "webroot":"test-harness", + "aliases":{ + "/moduleroot/sentry":"./" + } + }, + "JVM":{ + "javaVersion":"openjdk21_jdk_x64", + "args":"-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=9999 -Dboxlang.debugMode=true" + }, + "scripts":{ + "onServerInitialInstall":"install bx-compat-cfml,bx-esapi --noSave" + } +} \ No newline at end of file diff --git a/test-harness/tests/specs/SentryTests.cfc b/test-harness/tests/specs/SentryTests.cfc index d78a660..0ad755d 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,487 @@ 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" ); + } ); + + // ========== Dynamic TagContext Extraction from Java Stack Trace ========== + + it( "extracts CFML template references from Java stack trace into tagContext", function(){ + var service = prepareMock( getSentry() ); + service.setEnabled( true ); + service.$( "post" ); + + // Simulate a Lucee-style stack trace with CFML file references + var testException = { + "message" : "Null Pointer", + "detail" : "", + "type" : "application", + "TagContext" : [], + "StackTrace" : "java.lang.NullPointerException + at com_example_App_cfc$cf.call(/var/www/App.cfc:42) + at com_example_App_cfc$cf.call(/var/www/App.cfc:15) + at com_example_Main_cfc$cf.call(/var/www/Main.cfc:8) + at lucee.runtime.PageContextImpl._doInclude(PageContextImpl.java:1118) + at lucee.runtime.PageContextImpl.execute(PageContextImpl.java:2816)" + }; + + service.captureException( exception = testException, showJavaStackTrace = true ); + + var payload = deserializeJSON( service.$callLog( "post" ).post[ 1 ][ 4 ] ); + var excValues = payload.exception.values; + + // Should have CFML frames on the primary exception + var frames = excValues[ 1 ].stacktrace.frames; + expect( frames.len() ).toBe( 3 ); + + // Sentry frames are oldest-first: frames[1] is the originating + // call, frames[n] is where the exception was thrown + expect( frames[ 1 ].filename ).toInclude( "Main.cfc" ); + expect( frames[ 1 ].lineno ).toBe( 8 ); + expect( frames[ 2 ].filename ).toInclude( "App.cfc" ); + expect( frames[ 2 ].lineno ).toBe( 15 ); + expect( frames[ 3 ].filename ).toInclude( "App.cfc" ); + expect( frames[ 3 ].lineno ).toBe( 42 ); + } ); + + it( "extracts BoxLang .bx/.bxs/.bxm template references from stack trace", function(){ + var service = prepareMock( getSentry() ); + service.setEnabled( true ); + service.$( "post" ); + + var testException = { + "message" : "BoxLang Error", + "detail" : "", + "type" : "expression", + "TagContext" : [], + "StackTrace" : "java.lang.RuntimeException: boom + at boxlang.runtime.context.RequestContext.execute(/app/services/MyService.bx:55) + at boxlang.runtime.interceptor.InterceptorChain.invoke(/app/handlers/Main.bxs:12) + at boxlang.runtime.module.ModuleLoader.load(/app/modules/Payment.bxm:30) + at java.base/java.lang.Thread.run(Unknown Source)" + }; + + service.captureException( exception = testException, showJavaStackTrace = true ); + + var payload = deserializeJSON( service.$callLog( "post" ).post[ 1 ][ 4 ] ); + var frames = payload.exception.values[ 1 ].stacktrace.frames; + + expect( frames.len() ).toBe( 3 ); + // Sentry frames are oldest-first + expect( frames[ 1 ].filename ).toInclude( "Payment.bxm" ); + expect( frames[ 1 ].lineno ).toBe( 30 ); + expect( frames[ 2 ].filename ).toInclude( "Main.bxs" ); + expect( frames[ 2 ].lineno ).toBe( 12 ); + expect( frames[ 3 ].filename ).toInclude( "MyService.bx" ); + expect( frames[ 3 ].lineno ).toBe( 55 ); + } ); + + it( "returns empty tagContext for pure Java stack traces with no CFML references", function(){ + var service = prepareMock( getSentry() ); + service.setEnabled( true ); + service.$( "post" ); + + var testException = { + "message" : "Pure Java", + "detail" : "", + "type" : "application", + "TagContext" : [], + "StackTrace" : "java.lang.IllegalArgumentException: invalid argument + at java.util.Objects.requireNonNull(Objects.java:221) + at java.util.ArrayList.add(ArrayList.java:485) + at org.apache.commons.io.IOUtils.toString(IOUtilsUtils.java:2000) + at com.sun.tools.javac.Main.compile(Main.java:553)" + }; + + service.captureException( exception = testException, showJavaStackTrace = true ); + + var payload = deserializeJSON( service.$callLog( "post" ).post[ 1 ][ 4 ] ); + var excValues = payload.exception.values; + + // Primary CFML exception still exists but has no frames + expect( excValues[ 1 ].stacktrace.frames.len() ).toBe( 0 ); + + // Java exception frames are in separate entries from parseJavaStackTrace + expect( excValues.len() ).toBe( 2 ); + expect( excValues[ 2 ].type ).toBe( "java.lang.IllegalArgumentException" ); + } ); + + it( "normalizes Windows backslash paths in stack trace template references", function(){ + var service = prepareMock( getSentry() ); + service.setEnabled( true ); + service.$( "post" ); + + var testException = { + "message" : "Windows Path", + "detail" : "", + "type" : "application", + "TagContext" : [], + "StackTrace" : "java.lang.NullPointerException + at com_example_App_cfc$cf.call(C:\wwwroot\app\index.cfm:42) + at lucee.runtime.PageContextImpl.execute(PageContextImpl.java:2816)" + }; + + service.captureException( exception = testException, showJavaStackTrace = true ); + + var payload = deserializeJSON( service.$callLog( "post" ).post[ 1 ][ 4 ] ); + var frames = payload.exception.values[ 1 ].stacktrace.frames; + + expect( frames.len() ).toBe( 1 ); + // Should be normalized to forward slashes + expect( frames[ 1 ].filename ).toInclude( "C:/wwwroot/app/index.cfm" ); + expect( frames[ 1 ].lineno ).toBe( 42 ); + } ); + + it( "deduplicates repeated template+line combinations in tagContext", function(){ + var service = prepareMock( getSentry() ); + service.setEnabled( true ); + service.$( "post" ); + + var testException = { + "message" : "Duplicate", + "detail" : "", + "type" : "application", + "TagContext" : [], + "StackTrace" : "java.lang.RuntimeException: bang + at com_example_App_cfc$cf.call(/app/index.cfm:10) + at com_example_App_cfc$cf.call(/app/index.cfm:10) + at com_example_Other_cfc$cf.call(/app/other.cfm:20)" + }; + + service.captureException( exception = testException, showJavaStackTrace = true ); + + var payload = deserializeJSON( service.$callLog( "post" ).post[ 1 ][ 4 ] ); + var frames = payload.exception.values[ 1 ].stacktrace.frames; + + // Duplicate /app/index.cfm:10 should only appear once + expect( frames.len() ).toBe( 2 ); + } ); + + it( "finds function names via functionLineNums for synthetic tagContext frames", function(){ + var service = prepareMock( getSentry() ); + service.setEnabled( true ); + service.$( "post" ); + + // Replace the real functionLineNums property with a mock so + // we can control what findTagContextFunction returns + var mockFLN = createStub(); + mockFLN.$( "findTagContextFunction" ).$results( "MyApp.handler", "MyApp.interceptor" ); + service.$property( propertyName = "functionLineNums", mock = mockFLN ); + + var testException = { + "message" : "Function Names", + "detail" : "", + "type" : "application", + "TagContext" : [], + "StackTrace" : "java.lang.NullPointerException + at com_example_handler_cfc$cf.call(/app/handler.cfc:42) + at com_example_interceptor_cfc$cf.call(/app/interceptor.cfc:15)" + }; + + service.captureException( exception = testException, showJavaStackTrace = true ); + + var payload = deserializeJSON( service.$callLog( "post" ).post[ 1 ][ 4 ] ); + var frames = payload.exception.values[ 1 ].stacktrace.frames; + + // functionLineNums.findTagContextFunction() is called during frame building + expect( mockFLN.$count( "findTagContextFunction" ) ).toBe( 2 ); + } ); + + it( "skips invalid line numbers in stack trace template references", function(){ + var service = prepareMock( getSentry() ); + service.setEnabled( true ); + service.$( "post" ); + + var testException = { + "message" : "Bad Lines", + "detail" : "", + "type" : "application", + "TagContext" : [], + "StackTrace" : "java.lang.NullPointerException + at com_example_App_cfc$cf.call(/app/index.cfm:0) + at com_example_App_cfc$cf.call(/app/index.cfm:-1) + at com_example_App_cfc$cf.call(/app/index.cfm:15) + at com_example_App_cfc$cf.call(/app/index.cfm:_invalid_)" + }; + + service.captureException( exception = testException, showJavaStackTrace = true ); + + var payload = deserializeJSON( service.$callLog( "post" ).post[ 1 ][ 4 ] ); + var frames = payload.exception.values[ 1 ].stacktrace.frames; + + // Only the valid line 15 should be included + expect( frames.len() ).toBe( 1 ); + expect( frames[ 1 ].lineno ).toBe( 15 ); + } ); } ); }