Answer by Serhiy Onyshchenko on 16 May 2023
Hey, Guy Toyber ,
Thanks for reaching out to the community for this one.
There’s a way to configure Exalate to track the reasoning behind updates in a text custom field.
For this example, I’m using Jira <> Jira synchronization.
Here’s a video showing the solution:
Basically, I needed to configure first the conflict handling (from this community article: Avoid updating issue with older changes ADO <> Jira, Jira <> Jira, ServiceNow <> Jira  (old community)),
and then modify it quite a bit (Chapter 2) to have more useful info in the reasoning log.
Chapter 1: main logic
On the source side
I needed to add
replica.changeHistory       = issue.changeHistory
replica.created             = issue.created
to the outgoing sync script
On destination side
I need to create some sort of a place where I’d aggregate all the reasons for changes:
def changeLogBuilder = new StringBuilder();
I need to describe the reasons, why the source triggered synchronization:
def latestChange = replica
    .changeHistory
    .sort { c -> c.created.time }
    .reverse()
    .find()
def latestChangeMsg = (latestChange == null) ? 
    "${replica.key} created by ${userStr(replica.reporter)} on ${replica.created}" : 
    "${latestChange.changeItems.field.join(", ")} updated by ${userStr(latestChange.author)} on ${latestChange.created}"
    
def syncMsg = firstSync ?
    "First sync" :
    "Sync"
changeLogBuilder
    .append("# $syncMsg triggered due to $latestChangeMsg")
On the incoming, for every line like
issue.AAA = ...
I needed to add some reasoning to the changeLogBuilder:
changeLogBuilder
  .append("\n# ")
  .append(
       "Overwriting AAA because Exalate is configured to do it on every sync"
  )
if the line is surrounded by an if:
if (XXX && YYY) {
	issue.ZZZ = ...
}
Then both XXX and YYY need to be mentioned in the reasoning message:
 changeLogBuilder
    .append("\n# ")
    .append("Setting ZZZ to ... because XXX and YYY")
if the right hand side of the assignment uses a ternary statement:
issue.AAA = BBB ? CCC : DDD
then the condition BBB has to be mentioned in the message:
def msg = BBB ? 
          "BBB is true and so AAA is set to ${CCC}" : 
          "BBB is false and so AAA is set to ${DDD}"
note, that elvis operator:
issue.XXX = YYY ?: ZZZ
is just a shorter ternary (same thing can be said this way):
issue.XXX = (YYY == null) ? YYY : ZZZ
For comments and attachments, since we’re using these special functions mergeComments and mergeAttachments, the reasoning also takes a special form:
issue.comments     = commentHelper.mergeComments(issue, replica)
issue.attachments  = attachmentHelper.mergeAttachments(issue, replica)
replaced with
issue.comments     = commentHelper.mergeComments(issue, replica)
if (issue.comments != issueBeforeScript.comments) {
    if (!replica.removedComments.empty) {
        changeLogBuilder
         .append("\n# ")
         .append(
             "There were ${replica.removedComments.size()} comments removed on the remote side, so Exalate is going to remove them here too".toString()
         )
    }
    if (!replica.addedComments.empty) {
        changeLogBuilder
         .append("\n# ")
         .append(
             "There were ${replica.addedComments.size()} comments added on the remote side by ${replica.addedComments.collect { userStr(it?.author) }.join(", ")}, so Exalate is going to add them here too".toString()
         )
    }
}
issue.attachments  = attachmentHelper.mergeAttachments(issue, replica)
if (issue.attachments != issueBeforeScript.attachments) {
    if (!replica.removedAttachments.empty) {
        changeLogBuilder
         .append("\n# ")
         .append(
             "There were ${replica.removedComments.size()} attachments removed on the remote side, so Exalate is going to remove them here too".toString()
         )
    }
    if (!replica.addedAttachments.empty) {
        changeLogBuilder
         .append("\n# ")
         .append(
             "There were ${replica.addedAttachments.size()} attachments added on the remote side by ${replica.addedAttachments.collect { userStr(it?.author) }.join(", ")}, so Exalate is going to add them here too".toString()
         )
    }
}
if you’re using conflict handling script (Avoid updating issue with older changes ADO <> Jira, Jira <> Jira, ServiceNow <> Jira  (old community)) for some fields, than your code looks like this:
if (firstSync) {
  ...
  issue."BBB" = replica."AAA"
  ...
} else {
  ...
  if(remoteFieldToLastUpdateDate."AAA" > localFieldToLastUpdateDate."BBB") {
	issue."BBB" = replica."AAA"
  }
  ...
}
I’d modified the conflict handling helper functions a bit (discussed in Chapter 2), so now these blocks would look like this instead:
if (firstSync || remoteFn()?."AAA"?.date > localFn()?."BBB"?.date) {
  issue."BBB" = replica."AAA"
  def reason = firstSync ? 
        "it's first sync, and there is no local value yet" :
        "it was changed by ${userStr(remoteFn()?."AAA"?.who)} "+
        "more recently (${remoteFn()?."AAA"?.date}) on remote side "+
        "than on local side (${localFn()?."BBB"?.date})"
  changeLogBuilder
     .append("\n# ")
     .append(
         "Overwriting \"BBB\" because $reason".toString()
     )
} else {
  changeLogBuilder
     .append("\n# ")
     .append(
         "Not touching \"BBB\" because "+
         "it is not the first sync and "+
         "it was changed by ${userStr(localFn()?."BBB"?.who)} "+
         "more recently (${localFn()?."BBB"?.date}) locally "+
         "than on the remote side (${remoteFn()?."AAA"?.date})".toString()
     )
}
Finally, we’re making sure the change log we’d been accumulating this whole time is put into the “Exalate Change Reason” custom field:
issue."Exalate Change Reason" = changeLogBuilder.toString()
Chapter 2: helper methods and conflict handling update
So I had to change
// Community 42839472: Avoid updating issue with older changes ADO <> Jira
def fieldToLastUpdateDateFn = { exalateUserKey -> { history ->
    history
            .sort { c -> c.created.time }
            .reverse()
            .findAll { c ->
                c.author.key != exalateUserKey
            }
            .inject([:]) { result, c ->
                c.changeItems.inject(result) { r, i ->
                    String k = i.field
                    if (r[k] == null) {
                        r[k] = c.created
                    }
                    r
                }
            }
}}
into
def fieldToLastUpdateDateFn = { exalateUserKey -> { history ->
    history
        .sort { c -> c.created.time }
        .reverse()
        .findAll { c ->
            c.author.key != exalateUserKey
        }
        .inject([:]) { result, c ->
            c.changeItems.inject(result) { r, i ->
                String k = i.field
                if (r[k] == null) {
                    r[k] = ["date":c.created,"who":c.author]
                }
                r
            }
        }
}}
def changeDateFnFn = { exalateUser, timeDiffHours, changeHistory ->
    def cache = null
    def fn = { ->
        if(cache == null) {
            def HOUR = 1000 * 60 * 60
            def TIME_DIFF = timeDiffHours * HOUR
            cache = fieldToLastUpdateDateFn(exalateUser)(changeHistory)
                .inject([:]) { r, k, v ->
                    r[k] = v
                    v?.date = new Date((((v?.date instanceof Integer) ? v?.date : v?.date?.time) + TIME_DIFF) as Long)
                    r
                }
            cache
        } else {
            cache
        }
    }
    fn
}
and then it’s call later on:
def remoteFieldToLastUpdateDate = fieldToLastUpdateDateFn("exalate")(replica.changeHistory)
def localFieldToLastUpdateDate = fieldToLastUpdateDateFn("exalate")(issue.changeHistory)
became:
def remoteFn = changeDateFnFn("exalate", 0, replica.changeHistory)
def localFn = changeDateFnFn("exalate", 0, issue.changeHistory)
so then the conflict conditions changed from
if(firstSync) {
  issue."BBB" = replica."AAA"
} else { 
  if (remoteFieldToLastUpdateDate."AAA" > localFieldToLastUpdateDate."BBB") {
    issue."BBB" = replica."AAA"
  }}
to
if(firstSync || remoteFn()?."AAA"?.date > localFn()?."BBB"?.date) {
  issue."BBB" = replica."AAA"
}
I also introduced the userStr helper function that converts a user object into a read-able text:
def userStr = { usr ->
    (usr == null) ? 
    "Unknown user" :
    "${usr?.displayName} (${usr?.email})".toString()
}
Chapter 3: all code together
Outgoing on source:
// community 58501849 send history information to help with the reasoning
replica.changeHistory       = issue.changeHistory
replica.created             = issue.created
Incoming on destination:
def changeLogBuilder = new StringBuilder();
def userStr = { usr ->
    (usr == null) ? 
    "Unknown user" :
    "${usr?.displayName} (${usr?.email})".toString()
}
// community 58501849 start accumulating the change log
def latestChange = replica
    .changeHistory
    .sort { c -> c.created.time }
    .reverse()
    .find()
def latestChangeMsg = (latestChange == null) ? 
    "${replica.key} created by ${userStr(replica.reporter)} on ${replica.created}" : 
    "${latestChange.changeItems.field.join(", ")} updated by ${userStr(latestChange.author)} on ${latestChange.created}"
    
def syncMsg = firstSync ?
    "First sync" :
    "Sync"
changeLogBuilder
    .append("# $syncMsg triggered due to $latestChangeMsg")
// end: community 58501849 start accumulating the change log
if(firstSync) {
  issue.projectKey   = "BB"
// community 58501849 add to change log due to issue.projectKey
  changeLogBuilder
    .append("\n# ")
    .append("Setting project to ${issue.projectKey} because it's first sync coming from ${replica.project.key}") 
// end: community 58501849 add to change log due to issue.projectKey
  issue.typeName     = nodeHelper.getIssueType(replica.type?.name, issue.projectKey)?.name ?: "Task"
// community 58501849 add to change log due to issue.typeName
  def isSameTypeFound = nodeHelper.getIssueType(replica.type?.name, issue.projectKey)?.name != null
  def issueTypeMsg = isSameTypeFound ? 
          "same issue type as ${replica.type?.name} found in ${issue.projectKey}" : 
          "issue type ${replica.type?.name} not found in ${issue.projectKey} "+
          "so Exalate assigns a default issue type \"Task\""
  changeLogBuilder
    .append("\n# ")
    .append(
        "Setting issue type to ${issue.typeName} because "+
        "it's first sync coming from ${replica.project.key} "+
        "and $issueTypeMsg".toString()
    ) 
// end: community 58501849 add to change log due to issue.typeName
}
// community 58501849 + 58500894 conflict handling + useful info for change reasons
def fieldToLastUpdateDateFn = { exalateUserKey -> { history ->
    history
        .sort { c -> c.created.time }
        .reverse()
        .findAll { c ->
            c.author.key != exalateUserKey
        }
        .inject([:]) { result, c ->
            c.changeItems.inject(result) { r, i ->
                String k = i.field
                if (r[k] == null) {
                    r[k] = ["date":c.created,"who":c.author]
                }
                r
            }
        }
}}
def changeDateFnFn = { exalateUser, timeDiffHours, changeHistory ->
    def cache = null
    def fn = { ->
        if(cache == null) {
            def HOUR = 1000 * 60 * 60
            def TIME_DIFF = timeDiffHours * HOUR
            cache = fieldToLastUpdateDateFn(exalateUser)(changeHistory)
                .inject([:]) { r, k, v ->
                    r[k] = v
                    v?.date = new Date((((v?.date instanceof Integer) ? v?.date : v?.date?.time) + TIME_DIFF) as Long)
                    r
                }
            cache
        } else {
            cache
        }
    }
    fn
}
def remoteFn = changeDateFnFn("557058:c020323a-70e4-4c07-9ccc-3ad89b1c02ec", 0, replica.changeHistory) // 557058:c020323a-70e4-4c07-9ccc-3ad89b1c02ec is the exalate's user id for Jira Cloud
def localFn = changeDateFnFn("557058:c020323a-70e4-4c07-9ccc-3ad89b1c02ec", 0, issue.changeHistory) // 557058:c020323a-70e4-4c07-9ccc-3ad89b1c02ec is the exalate's user id for Jira Cloud 
// end: community 58501849 + 58500894 conflict handling + useful info for change reasons
issue.comments     = commentHelper.mergeComments(issue, replica)
// community 58501849 logging comment changes into the change log
if (issue.comments != issueBeforeScript.comments) {
    if (!replica.removedComments.empty) {
        changeLogBuilder
         .append("\n# ")
         .append(
             "There were ${replica.removedComments.size()} comments removed on the remote side, so Exalate is going to remove them here too".toString()
         )
    }
    if (!replica.addedComments.empty) {
        changeLogBuilder
         .append("\n# ")
         .append(
             "There were ${replica.addedComments.size()} comments added on the remote side by ${replica.addedComments.collect { userStr(it?.author) }.join(", ")}, so Exalate is going to add them here too".toString()
         )
    }
}
// end: community 58501849 logging comment changes into the change log
issue.attachments  = attachmentHelper.mergeAttachments(issue, replica)
// community 58501849 logging attachment changes into the change log
if (issue.attachments != issueBeforeScript.attachments) {
    if (!replica.removedAttachments.empty) {
        changeLogBuilder
         .append("\n# ")
         .append(
             "There were ${replica.removedComments.size()} attachments removed on the remote side, so Exalate is going to remove them here too".toString()
         )
    }
    if (!replica.addedAttachments.empty) {
        changeLogBuilder
         .append("\n# ")
         .append(
             "There were ${replica.addedAttachments.size()} attachments added on the remote side by ${replica.addedAttachments.collect { userStr(it?.author) }.join(", ")}, so Exalate is going to add them here too".toString()
         )
    }
}
// end: community 58501849 logging attachment changes into the change log
issue.labels       = replica.labels
// community 58501849 logging label changes into the change log
if (issue.labels != issueBeforeScript.labels) {
    changeLogBuilder
     .append("\n# ")
     .append(
         "Overwriting labels because Exalate is configured to do it on every sync"
     )
}
// end: community 58501849 logging label changes into the change log
// community 58501849 + 58500894 conflict handling = logging why was a field changed or left untouched
if (firstSync || remoteFn()?."description"?.date > localFn()?."description"?.date) {
    issue.description = replica.description
    def reason = firstSync ? 
        "it's first sync, and there is no local value yet" :
        "it was changed by ${userStr(remoteFn()?."description"?.who)} "+
        "more recently (${remoteFn()?."description"?.date}) on remote side "+
        "than on local side (${localFn()?."description"?.date})"
    changeLogBuilder
     .append("\n# ")
     .append(
         "Overwriting description because $reason".toString()
     )
} else {
    changeLogBuilder
     .append("\n# ")
     .append(
         "Not touching \"description\" because "+
         "it is not the first sync and "+
         "it was changed by ${userStr(localFn()?."description"?.who)} "+
         "more recently (${localFn()?."description"?.date}) locally "+
         "than on the remote side (${remoteFn()?."description"?.date})".toString()
     )
}
if (firstSync || remoteFn()."summary"?.date > localFn()."summary"?.date) {
    issue.summary = replica.summary
    def reason = firstSync ? 
        "it's first sync, and there is no local value yet" :
        "it was changed by ${userStr(remoteFn()?."summary"?.who)} "+
        "more recently (${remoteFn()?."summary"?.date}) on remote side "+
        "than on local side (${localFn()?."summary"?.date})"
    changeLogBuilder
     .append("\n# ")
     .append(
         "Overwriting \"summary\" because $reason".toString()
     )
} else {
    changeLogBuilder
     .append("\n# ")
     .append(
         "Not touching \"summary\" because "+
         "it is not the first sync and "+
         "it was changed by ${userStr(localFn()?."summary"?.who)} "+
         "more recently (${localFn()?."summary"?.date}) locally "+
         "than on the remote side (${remoteFn()?."summary"?.date})".toString()
     )
}
if (firstSync || remoteFn()?."Classification"?.date > remoteFn()?."Classification"?.date) {         
    issue.Classification = replica.Classification
    def reason = firstSync ? 
        "it's first sync, and there is no local value yet" :
        "it was changed by ${userStr(remoteFn()?."Classification"?.who)} "+
        "more recently (${remoteFn()?."Classification"?.date}) on remote side "+
        "than on local side (${localFn()?."Classification"?.date})"
    changeLogBuilder
     .append("\n# ")
     .append(
         "Overwriting \"Classification\" because $reason".toString()
     )
} else {
    changeLogBuilder
     .append("\n# ")
     .append(
         "Not touching \"Classification\" because "+
         "it is not the first sync and "+
         "it was changed by ${userStr(localFn()?."Classification"?.who)} "+
         "more recently (${localFn()?."Classification"?.date}) locally "+
         "than on the remote side (${remoteFn()?."Classification"?.date})".toString()
     )
}
// end: community 58501849 + 58500894 conflict handling = logging why was a field changed or left untouched
// community 58501849 adding the change log to a custom field
issue."Exalate Change Reason" = changeLogBuilder.toString()
// end: community 58501849 adding the change log to a custom field
Let me know how it goes,
Happy Exalating!