Skip to content

Commit

Permalink
feat: very simple exception fingerprinting (#23157)
Browse files Browse the repository at this point in the history
Co-authored-by: David Newell <[email protected]>
  • Loading branch information
pauldambra and daibhin authored Jul 11, 2024
1 parent d129190 commit c5bba77
Show file tree
Hide file tree
Showing 5 changed files with 210 additions and 2 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { captureException } from '@sentry/node'
import { Counter } from 'prom-client'

import { PreIngestionEvent } from '../../../types'
import { EventPipelineRunner } from './runner'

const EXTERNAL_FINGERPRINT_COUNTER = new Counter({
name: 'enrich_exception_events_external_fingerprint',
help: 'Counter for exceptions that already have a fingerprint',
})

const COULD_NOT_PARSE_STACK_TRACE_COUNTER = new Counter({
name: 'enrich_exception_events_could_not_parse_stack_trace',
help: 'Counter for exceptions where the stack trace could not be parsed',
})

const COULD_NOT_PREPARE_FOR_FINGERPRINTING_COUNTER = new Counter({
name: 'enrich_exception_events_could_not_prepare_for_fingerprinting',
help: 'Counter for exceptions where the event could not be prepared for fingerprinting',
})

const EXCEPTIONS_ENRICHED_COUNTER = new Counter({
name: 'enrich_exception_events_enriched',
help: 'Counter for exceptions that have been enriched',
})

export function enrichExceptionEventStep(
_runner: EventPipelineRunner,
event: PreIngestionEvent
): Promise<PreIngestionEvent> {
if (event.event !== '$exception') {
return Promise.resolve(event)
}

let type: string | null = null
let message: string | null = null
let firstFunction: string | null = null
let exceptionStack: string | null = null

try {
exceptionStack = event.properties['$exception_stack_trace_raw']
const fingerPrint = event.properties['$exception_fingerprint']
type = event.properties['$exception_type']
message = event.properties['$exception_message']

if (fingerPrint) {
EXTERNAL_FINGERPRINT_COUNTER.inc()
return Promise.resolve(event)
}
} catch (e) {
captureException(e)
COULD_NOT_PREPARE_FOR_FINGERPRINTING_COUNTER.inc()
}

try {
if (exceptionStack) {
const parsedStack = JSON.parse(exceptionStack)
if (parsedStack.length > 0) {
firstFunction = parsedStack[0].function
}
}
} catch (e) {
captureException(e)
COULD_NOT_PARSE_STACK_TRACE_COUNTER.inc()
}

const fingerprint = [type, message, firstFunction].filter(Boolean)
event.properties['$exception_fingerprint'] = fingerprint.length ? fingerprint : undefined

if (event.properties['$exception_fingerprint']) {
EXCEPTIONS_ENRICHED_COUNTER.inc()
}

return Promise.resolve(event)
}
9 changes: 8 additions & 1 deletion plugin-server/src/worker/ingestion/event-pipeline/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { normalizeProcessPerson } from '../../../utils/event'
import { status } from '../../../utils/status'
import { captureIngestionWarning, generateEventDeadLetterQueueMessage } from '../utils'
import { createEventStep } from './createEventStep'
import { enrichExceptionEventStep } from './enrichExceptionEventStep'
import { extractHeatmapDataStep } from './extractHeatmapDataStep'
import {
eventProcessedAndIngestedCounter,
Expand Down Expand Up @@ -264,9 +265,15 @@ export class EventPipelineRunner {
kafkaAcks.push(...heatmapKafkaAcks)
}

const enrichedIfErrorEvent = await this.runStep(
enrichExceptionEventStep,
[this, preparedEventWithoutHeatmaps],
event.team_id
)

const [rawClickhouseEvent, eventAck] = await this.runStep(
createEventStep,
[this, preparedEventWithoutHeatmaps, person, processPerson],
[this, enrichedIfErrorEvent, person, processPerson],
event.team_id
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,21 @@ Array [
},
],
],
Array [
"enrichExceptionEventStep",
Array [
Object {
"distinctId": "my_id",
"elementsList": Array [],
"event": "$pageview",
"eventUuid": "uuid1",
"ip": "127.0.0.1",
"properties": Object {},
"teamId": 2,
"timestamp": "2020-02-23T02:15:00.000Z",
},
],
],
Array [
"createEventStep",
Array [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { ISOTimestamp, PreIngestionEvent } from '../../../../src/types'
import { cloneObject } from '../../../../src/utils/utils'
import { enrichExceptionEventStep } from '../../../../src/worker/ingestion/event-pipeline/enrichExceptionEventStep'

jest.mock('../../../../src/worker/plugins/run')

const aStackTrace =
'[{"filename":"http://localhost:8234/static/chunk-VDD5ZZ2W.js","function":"dependenciesChecker","in_app":true,"lineno":721,"colno":42},{"filename":"http://localhost:8234/static/chunk-VDD5ZZ2W.js","function":"?","in_app":true,"lineno":2474,"colno":40},{"filename":"http://localhost:8234/static/chunk-VDD5ZZ2W.js","function":"Object.memoized [as tiles]","in_app":true,"lineno":632,"colno":24},{"filename":"http://localhost:8234/static/chunk-VDD5ZZ2W.js","function":"dependenciesChecker","in_app":true,"lineno":721,"colno":42},{"filename":"http://localhost:8234/static/chunk-VDD5ZZ2W.js","function":"memoized","in_app":true,"lineno":632,"colno":24},{"filename":"http://localhost:8234/static/chunk-VDD5ZZ2W.js","function":"dependenciesChecker","in_app":true,"lineno":721,"colno":42},{"filename":"http://localhost:8234/static/chunk-VDD5ZZ2W.js","function":"logic.selector","in_app":true,"lineno":2517,"colno":18},{"filename":"http://localhost:8234/static/chunk-VDD5ZZ2W.js","function":"pathSelector","in_app":true,"lineno":2622,"colno":37},{"filename":"<anonymous>","function":"Array.reduce","in_app":true},{"filename":"http://localhost:8234/static/chunk-VDD5ZZ2W.js","function":"?","in_app":true,"lineno":2626,"colno":15}]'

const preIngestionEvent: PreIngestionEvent = {
eventUuid: '018eebf3-cb48-750b-bfad-36409ea6f2b2',
event: '$exception',
distinctId: '018eebf3-79b1-7082-a7c6-eeb56a36002f',
properties: {
$current_url: 'http://localhost:3000/',
$host: 'localhost:3000',
$pathname: '/',
$viewport_height: 1328,
$viewport_width: 1071,
$device_id: '018eebf3-79b1-7082-a7c6-eeb56a36002f',
$session_id: '018eebf3-79cd-70da-895f-b6cf352bd688',
$window_id: '018eebf3-79cd-70da-895f-b6d09add936a',
},
timestamp: '2024-04-17T12:06:46.861Z' as ISOTimestamp,
teamId: 1,
}

describe('enrichExceptionEvent()', () => {
let runner: any
let event: PreIngestionEvent

beforeEach(() => {
event = cloneObject(preIngestionEvent)
runner = {
hub: {
kafkaProducer: {
produce: jest.fn((e) => Promise.resolve(e)),
},
},
nextStep: (...args: any[]) => args,
}
})

it('ignores non-exception events - even if they have a stack trace', async () => {
event.event = 'not_exception'
event.properties['$exception_stack_trace_raw'] = '[{"some": "data"}]'
expect(event.properties['$exception_fingerprint']).toBeUndefined()

const response = await enrichExceptionEventStep(runner, event)
expect(response).toBe(event)
})

it('use a fingerprint if it is present', async () => {
event.event = '$exception'
event.properties['$exception_stack_trace_raw'] = '[{"some": "data"}]'
event.properties['$exception_fingerprint'] = 'some-fingerprint'

const response = await enrichExceptionEventStep(runner, event)

expect(response.properties['$exception_fingerprint']).toBe('some-fingerprint')
})

it('uses the message and stack trace as the simplest grouping', async () => {
event.event = '$exception'
event.properties['$exception_message'] = 'some-message'
event.properties['$exception_stack_trace_raw'] = aStackTrace

const response = await enrichExceptionEventStep(runner, event)

expect(response.properties['$exception_fingerprint']).toStrictEqual(['some-message', 'dependenciesChecker'])
})

it('includes type in stack grouping when present', async () => {
event.event = '$exception'
event.properties['$exception_message'] = 'some-message'
event.properties['$exception_stack_trace_raw'] = aStackTrace
event.properties['$exception_type'] = 'UnhandledRejection'

const response = await enrichExceptionEventStep(runner, event)

expect(response.properties['$exception_fingerprint']).toStrictEqual([
'UnhandledRejection',
'some-message',
'dependenciesChecker',
])
})

it('falls back to message and type when no stack trace', async () => {
event.event = '$exception'
event.properties['$exception_message'] = 'some-message'
event.properties['$exception_stack_trace_raw'] = null
event.properties['$exception_type'] = 'UnhandledRejection'

const response = await enrichExceptionEventStep(runner, event)

expect(response.properties['$exception_fingerprint']).toStrictEqual(['UnhandledRejection', 'some-message'])
})

it('adds no fingerprint if no qualifying properties', async () => {
event.event = '$exception'
event.properties['$exception_message'] = null
event.properties['$exception_stack_trace_raw'] = null
event.properties['$exception_type'] = null

const response = await enrichExceptionEventStep(runner, event)

expect(response.properties['$exception_fingerprint']).toBeUndefined()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ describe('EventPipelineRunner', () => {
'processPersonsStep',
'prepareEventStep',
'extractHeatmapDataStep',
'enrichExceptionEventStep',
'createEventStep',
])
expect(runner.stepsWithArgs).toMatchSnapshot()
Expand Down Expand Up @@ -153,6 +154,7 @@ describe('EventPipelineRunner', () => {
'processPersonsStep',
'prepareEventStep',
'extractHeatmapDataStep',
'enrichExceptionEventStep',
'createEventStep',
])
})
Expand All @@ -175,7 +177,7 @@ describe('EventPipelineRunner', () => {
const result = await runner.runEventPipeline(pipelineEvent)
expect(result.error).toBeUndefined()

expect(pipelineStepMsSummarySpy).toHaveBeenCalledTimes(7)
expect(pipelineStepMsSummarySpy).toHaveBeenCalledTimes(8)
expect(pipelineLastStepCounterSpy).toHaveBeenCalledTimes(1)
expect(eventProcessedAndIngestedCounterSpy).toHaveBeenCalledTimes(1)
expect(pipelineStepMsSummarySpy).toHaveBeenCalledWith('createEventStep')
Expand Down

0 comments on commit c5bba77

Please sign in to comment.