Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add callback option to config to capture context when starting transactions and spans #1525

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
62 changes: 62 additions & 0 deletions docs/configuration.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -419,3 +419,65 @@ This is useful on scenarios where the APM server is behind a reverse proxy that

NOTE: If APM Server is deployed in an origin different than the page’s origin, you will need to
<<configuring-cors, configure Cross-Origin Resource Sharing (CORS)>>.


[float]
[[transaction-context-callback]]
==== `transactionContextCallback`

* *Type:* Function
* *Default:* `null`

`transactionContextCallback` allows the agent to specify a function to be called when starting automatically instrumented transactions and return context to
be set as tags. This enables the agent to capture data such as call stack frames and variable values from the scope when instrumented events are fired from
files which do not import the RUM agent library.

The following example illustrates an example which captures the stack trace:

[source,js]
----
var options = {
transactionContextCallback: () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not a big fan of this approach and exposing configurations feels unncessary. Why not adding custom properites to transaction context via

apm.SetCustomContext

You can basically call this function apm.SetCustomContext anywhere and get the context added to the transaction/error events

apm.Observe

You can rely on the events when any transaction gets started/ended and add the relevant tags/context specific information.

apm.observe('transaction:start', function (transaction) {
    stack = "" // error stack
    transaction.addLabels({ stack })
})

Let me know what you think.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vigneshshanmugam the callback approach makes an important difference when context needs to be captured from an auto-instrumented transaction which starts in a file which is loaded by the application but does not import the RUM agent library. Using the built-in setCustomContext approach will not show the URL of the file where such a transaction started in a predictable order in the stack trace if we are to capture it as you have described. The callback approach will guarantee it will occur at the top of the stack trace. The use case we are interested in is quickly identifying the developer responsible for maintaining the code where a problematic transaction starts, so the callback approach is the only way to guarantee that we can immediately filter out the other files (when the original file does not import the library). Please let me know if you have other questions.

let stack
try {
throw new Error('')
}
catch (error) {
stack = (error as Error).stack || ''
}
stack = stack.split('\n').map(function (line) { return line.trim(); })
return { stack };
}
}
----


[float]
[[span-context-callback]]
==== `spanContextCallback`

* *Type:* Function
* *Default:* `null`

`spanContextCallback` allows the agent to specify a function to be called when starting automatically instrumented spans and return context to be set as tags.
This enables the agent to capture data such as call stack frames and variable values from the scope when instrumented events are fired from files which do
not import the RUM agent library.

The following example illustrates an example which captures the stack trace:

[source,js]
----
var options = {
spanContextCallback: () => {
let stack
try {
throw new Error('')
}
catch (error) {
stack = (error as Error).stack || ''
}
stack = stack.split('\n').map(function (line) { return line.trim(); })
return { stack };
}
}
----
4 changes: 3 additions & 1 deletion packages/rum-core/src/common/config-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,9 @@ class Config {
context: {},
session: false,
apmRequest: null,
sendCredentials: false
sendCredentials: false,
transactionContextCallback: null,
spanContextCallback: null
}

this.events = new EventHandler()
Expand Down
4 changes: 4 additions & 0 deletions packages/rum-core/src/performance-monitoring/span.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ class Span extends SpanBase {
this.action = fields[2]
}
this.sync = this.options.sync

if (this.options.spanContextCallback) {
this.addLabels(this.options.spanContextCallback())
}
}

end(endTime, data) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,32 @@ class TransactionService {

createOptions(options) {
const config = this._config.config
let presetOptions = { transactionSampleRate: config.transactionSampleRate }
const logger = this._logger
let presetOptions = {
transactionSampleRate: config.transactionSampleRate
}
if (typeof config.transactionContextCallback === 'function') {
presetOptions.transactionContextCallback = function () {
let tags = {}
try {
tags = config.transactionContextCallback()
} catch (err) {
logger.error('Failed to execute transaction context callback', err)
}
return tags
}
}
if (typeof config.spanContextCallback === 'function') {
presetOptions.spanContextCallback = function () {
let tags = {}
try {
tags = config.spanContextCallback()
} catch (err) {
logger.error('Failed to execute span context callback', err)
}
return tags
}
}
let perfOptions = extend(presetOptions, options)
if (perfOptions.managed) {
perfOptions = extend(
Expand Down Expand Up @@ -284,7 +309,7 @@ class TransactionService {
if (name === NAME_UNKNOWN && pageLoadTransactionName) {
tr.name = pageLoadTransactionName
}

/**
* Capture the TBT as span after observing for all long task entries
* and once performance observer is disconnected
Expand Down
10 changes: 9 additions & 1 deletion packages/rum-core/src/performance-monitoring/transaction.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ class Transaction extends SpanBase {

this.sampleRate = this.options.transactionSampleRate
this.sampled = Math.random() <= this.sampleRate

if (this.options.transactionContextCallback) {
this.addLabels(this.options.transactionContextCallback())
}
}

addMarks(obj) {
Expand Down Expand Up @@ -96,7 +100,11 @@ class Transaction extends SpanBase {
if (this.ended) {
return
}
const opts = extend({}, options)
let opts = extend({}, options)

if (this.options.spanContextCallback) {
opts.spanContextCallback = this.options.spanContextCallback
}

opts.onEnd = trc => {
this._onSpanEnd(trc)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -651,6 +651,95 @@ describe('TransactionService', function () {

transaction.end(pageLoadTime + 1000)
})

it('should capture tags from transaction dispatch context', done => {
config.setConfig({
transactionContextCallback: () => {
let stack
try {
throw new Error('')
} catch (error) {
stack = error.stack || ''
}
stack = stack.split('\n').map(function (line) {
return line.trim()
})
return { stack }
}
})
const transactionService = new TransactionService(logger, config)

const tr1 = transactionService.startTransaction(
'transaction1',
'transaction'
)

tr1.onEnd = () => {
expect(tr1.context.tags.stack).toBeTruthy()
done()
}
tr1.end()
})

it('should capture tags from span dispatch context', done => {
config.setConfig({
spanContextCallback: () => {
let stack
try {
throw new Error('')
} catch (error) {
stack = error.stack || ''
}
stack = stack.split('\n').map(function (line) {
return line.trim()
})
return { stack }
}
})
const transactionService = new TransactionService(logger, config)

const sp1 = transactionService.startSpan('span1', 'span')

sp1.onEnd = () => {
expect(sp1.context.tags.stack).toBeTruthy()
done()
}
sp1.end()
})
cjr125 marked this conversation as resolved.
Show resolved Hide resolved

it('should safely catch and log errors for an invalid callback', () => {
logger = new LoggingService()
spyOn(logger, 'error')

config.setConfig({
transactionContextCallback: () => {
throw new Error('Error in transaction callback')
},
spanContextCallback: () => {
throw new Error('Error in span callback')
}
})
const transactionService = new TransactionService(logger, config)

const tr1 = transactionService.startTransaction(
'transaction1',
'transaction'
)
expect(logger.error).toHaveBeenCalledWith(
'Failed to execute transaction context callback',
new Error('Error in transaction callback')
)
logger.error.calls.reset()

const sp1 = tr1.startSpan('span1', 'span')
expect(logger.error).toHaveBeenCalledWith(
'Failed to execute span context callback',
new Error('Error in span callback')
)

sp1.end()
tr1.end()
})
})

it('should truncate active spans after transaction ends', () => {
Expand Down
2 changes: 2 additions & 0 deletions packages/rum/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ declare module '@elastic/apm-rum' {
payload?: string
headers?: Record<string, string>
}) => boolean
transactionContextCallback?: () => Labels
spanContextCallback?: () => Labels
}

type Init = (options?: AgentConfigOptions) => ApmBase
Expand Down