Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,13 @@ export const CHROME_111_SNIPPET = {
at snippet:///snippet_file:1:13`,
}

export const CHROME_141_HTML_ANONYMOUS_LISTENER = {
message: 'message string',
name: 'Error',
stack: `Error: message string
at HTMLButtonElement.<anonymous> @ http://path/to/file.js:1:4287`,
}

export const PHANTOMJS_1_19 = {
stack: `Error: foo
at file:///path/to/file.js:878
Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/tools/stackTrace/computeStackTrace.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { isSafari } from '../utils/browserDetection'
import * as CapturedExceptions from './capturedExceptions.specHelper'
import { CHROME_141_HTML_ANONYMOUS_LISTENER } from './capturedExceptions.specHelper'
import { computeStackTrace } from './computeStackTrace'

describe('computeStackTrace', () => {
Expand Down Expand Up @@ -1004,4 +1005,17 @@ Error: foo
column: undefined,
})
})

it('should parse stack from html button click listener', () => {
const stackFrames = computeStackTrace(CHROME_141_HTML_ANONYMOUS_LISTENER)

expect(stackFrames.stack.length).toEqual(1)
expect(stackFrames.stack[0]).toEqual({
args: [],
column: 4287,
func: 'HTMLButtonElement.<anonymous>',
line: 1,
url: 'http://path/to/file.js',
})
})
})
14 changes: 9 additions & 5 deletions packages/core/src/tools/stackTrace/computeStackTrace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,11 @@ function parseChromeLine(line: string): StackFrame | undefined {
}
}

const CHROME_ANONYMOUS_FUNCTION_RE = new RegExp(`^\\s*at ?${fileUrl}${filePosition}?${filePosition}??\\s*$`, 'i')
const htmlAnonymousPart = '(?:(.*)?(?: @))'
const CHROME_ANONYMOUS_FUNCTION_RE = new RegExp(
`^\\s*at\\s*${htmlAnonymousPart}?\\s*${fileUrl}${filePosition}?${filePosition}??\\s*$`,
'i'
)

function parseChromeAnonymousLine(line: string): StackFrame | undefined {
const parts = CHROME_ANONYMOUS_FUNCTION_RE.exec(line)
Expand All @@ -122,10 +126,10 @@ function parseChromeAnonymousLine(line: string): StackFrame | undefined {

return {
args: [],
column: parts[3] ? +parts[3] : undefined,
func: UNKNOWN_FUNCTION,
line: parts[2] ? +parts[2] : undefined,
url: parts[1],
column: parts[4] ? +parts[4] : undefined,
func: parts[1] || UNKNOWN_FUNCTION,
line: parts[3] ? +parts[3] : undefined,
url: parts[2],
}
}

Expand Down
2 changes: 2 additions & 0 deletions packages/rum-core/src/boot/startRum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import type { Hooks } from '../domain/hooks'
import { createHooks } from '../domain/hooks'
import { startEventCollection } from '../domain/event/eventCollection'
import { startInitialViewMetricsTelemetry } from '../domain/view/viewMetrics/startInitialViewMetricsTelemetry'
import { startSourceCodeContext } from '../domain/contexts/sourceCodeContext'
import type { RecorderApi, ProfilerApi } from './rumPublicApi'

export type StartRum = typeof startRum
Expand Down Expand Up @@ -139,6 +140,7 @@ export function startRum(
startSessionContext(hooks, session, recorderApi, viewHistory)
startConnectivityContext(hooks)
startTrackingConsentContext(hooks, trackingConsentState)
startSourceCodeContext(hooks)
const globalContext = startGlobalContext(hooks, configuration, 'rum', true)
const userContext = startUserContext(hooks, configuration, session, 'rum')
const accountContext = startAccountContext(hooks, configuration, 'rum')
Expand Down
2 changes: 2 additions & 0 deletions packages/rum-core/src/domain/assembly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ export function startRumAssembly(
({ startTime, duration, rawRumEvent, domainContext }) => {
const defaultRumEventAttributes = hooks.triggerHook(HookNames.Assemble, {
eventType: rawRumEvent.type,
rawRumEvent,
domainContext,
startTime,
duration,
})!
Expand Down
46 changes: 46 additions & 0 deletions packages/rum-core/src/domain/contexts/sourceCodeContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { Context } from '@datadog/browser-core'
import { SKIPPED, computeStackTrace, objectEntries, addTelemetryError, HookNames } from '@datadog/browser-core'
import type { Hooks, DefaultRumEventAttributes } from '../hooks'

interface BrowserWindow {
DD_SOURCE_CODE_CONTEXT?: { [stack: string]: Context }
}
export function startSourceCodeContext(hooks: Hooks) {
const browserWindow = window as BrowserWindow
browserWindow.DD_SOURCE_CODE_CONTEXT = browserWindow.DD_SOURCE_CODE_CONTEXT || {}
const contextByFile = new Map<string, Context>()

objectEntries(browserWindow.DD_SOURCE_CODE_CONTEXT).forEach(([stack, context]) => {
const stackTrace = computeStackTrace({ stack })
const firstFrame = stackTrace.stack[0]
if (firstFrame.url) {
contextByFile.set(firstFrame.url, context)
} else {
addTelemetryError('Source code context: missing frame url', { stack })
}
})

// TODO: allow late global variable update to be taken into account

hooks.register(HookNames.Assemble, ({ domainContext, rawRumEvent }) => {
let stack
if ('handling_stack' in domainContext) {
stack = domainContext.handling_stack
}
if (rawRumEvent.type === 'error' && 'stack' in rawRumEvent.error) {
stack = rawRumEvent.error.stack
}
if (!stack) {
return SKIPPED
}
const stackTrace = computeStackTrace({ stack })
const firstFrame = stackTrace.stack[0]
if (firstFrame.url) {
const context = contextByFile.get(firstFrame.url)
if (context) {
return context as DefaultRumEventAttributes
}
}
return SKIPPED
})
}
4 changes: 4 additions & 0 deletions packages/rum-core/src/domain/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import type {
} from '@datadog/browser-core'
import { abstractHooks } from '@datadog/browser-core'
import type { RumEvent } from '../rumEvent.types'
import type { RawRumEvent } from '../rawRumEvent.types'
import type { RumEventDomainContext } from '../domainContext.types'

// Define a partial RUM event type.
// Ensuring the `type` field is always present improves type checking, especially in conditional logic in hooks (e.g., `if (eventType === 'view')`).
Expand All @@ -18,6 +20,8 @@ export type DefaultTelemetryEventAttributes = RecursivePartial<TelemetryEvent>
export interface HookCallbackMap {
[HookNamesAsConst.ASSEMBLE]: (param: {
eventType: RumEvent['type']
rawRumEvent: RawRumEvent
domainContext: RumEventDomainContext<RawRumEvent['type']>
startTime: RelativeTime
duration?: Duration | undefined
}) => DefaultRumEventAttributes | SKIPPED | DISCARDED
Expand Down