diff --git a/workers/sentry/package.json b/workers/sentry/package.json index 145fedc3..76d69cfb 100644 --- a/workers/sentry/package.json +++ b/workers/sentry/package.json @@ -1,6 +1,6 @@ { "name": "hawk-worker-sentry", - "version": "1.0.1", + "version": "1.1.0", "description": "Handles events in Sentry format", "main": "src/index.ts", "author": "CodeX", diff --git a/workers/sentry/src/index.ts b/workers/sentry/src/index.ts index be57bcfb..a98085da 100644 --- a/workers/sentry/src/index.ts +++ b/workers/sentry/src/index.ts @@ -4,7 +4,7 @@ import { DefaultEventWorkerTask } from '../../default/types/default-event-worker import WorkerNames from '../../../lib/workerNames.js'; import { Envelope, EnvelopeItem, EventEnvelope, EventItem, parseEnvelope } from '@sentry/core'; import { Worker } from '../../../lib/worker'; -import { composeAddons, composeBacktrace, composeContext, composeTitle, composeUserData } from './utils/converter'; +import { composeAddons, composeBacktrace, composeBreadcrumbs, composeContext, composeTitle, composeUserData } from './utils/converter'; import { b64decode } from './utils/base64'; import { CatcherMessagePayload } from '@hawk.so/types'; import { TextDecoder } from 'util'; @@ -325,6 +325,7 @@ export default class SentryEventWorker extends Worker { const backtrace = composeBacktrace(eventPayload); const context = composeContext(eventPayload); const user = composeUserData(eventPayload); + const breadcrumbs = composeBreadcrumbs(eventPayload); const addons = composeAddons(eventPayload); const event: CatcherMessagePayload<'errors/default' | 'errors/javascript'> = { @@ -345,6 +346,10 @@ export default class SentryEventWorker extends Worker { event.user = user; } + if (breadcrumbs) { + event.breadcrumbs = breadcrumbs; + } + if (addons) { event.addons = { sentry: addons, diff --git a/workers/sentry/src/utils/converter.ts b/workers/sentry/src/utils/converter.ts index ba7f6eb6..961613ba 100644 --- a/workers/sentry/src/utils/converter.ts +++ b/workers/sentry/src/utils/converter.ts @@ -1,5 +1,5 @@ -import { BacktraceFrame, DefaultAddons, EventContext, EventData, Json, SentryAddons } from '@hawk.so/types'; -import { Event as SentryEvent } from '@sentry/core'; +import { BacktraceFrame, Breadcrumb, DefaultAddons, EventContext, EventData, Json, JsonNode, SentryAddons } from '@hawk.so/types'; +import { Breadcrumb as SentryBreadcrumb, Event as SentryEvent } from '@sentry/core'; /** * Flattens a nested object into an array of strings using dot notation @@ -210,3 +210,101 @@ export function composeUserData(eventPayload: SentryEvent): EventData = { + default: 'default', + debug: 'default', + info: 'default', + http: 'request', + query: 'request', + navigation: 'navigation', + ui: 'ui', + user: 'ui', + error: 'error', + transaction: 'logic', +}; + +/** + * Converts a Sentry timestamp to Unix milliseconds used by Hawk breadcrumbs. + * Sentry sends either Unix seconds (with a fraction) or an RFC 3339 string. + * + * @param timestamp - Sentry timestamp + */ +function toMilliseconds(timestamp: unknown): number | undefined { + const msInSecond = 1000; + + if (typeof timestamp === 'number' && Number.isFinite(timestamp)) { + return Math.round(timestamp * msInSecond); + } + + if (typeof timestamp === 'string') { + const parsed = Date.parse(timestamp); + + return Number.isNaN(parsed) ? undefined : parsed; + } + + return undefined; +} + +/** + * Compose breadcrumbs from Sentry event payload. + * SDKs send them either as an array or wrapped into { values: [...] } (Python, PHP). + * A breadcrumb without its own timestamp gets the event timestamp. + * + * @param eventPayload - Sentry event payload + */ +export function composeBreadcrumbs(eventPayload: SentryEvent): Breadcrumb[] | undefined { + const raw: unknown = eventPayload.breadcrumbs; + const items: unknown = Array.isArray(raw) ? raw : (raw as { values?: unknown } | undefined)?.values; + + if (!Array.isArray(items)) { + return undefined; + } + + const eventTimestamp = toMilliseconds(eventPayload.timestamp); + const breadcrumbs: Breadcrumb[] = []; + + (items as SentryBreadcrumb[]).forEach((item) => { + if (typeof item !== 'object' || item === null) { + return; + } + + const timestamp = toMilliseconds(item.timestamp) ?? eventTimestamp; + + if (timestamp === undefined) { + return; + } + + const breadcrumb: Breadcrumb = { timestamp }; + + if (typeof item.type === 'string') { + breadcrumb.type = SENTRY_TO_HAWK_BREADCRUMB_TYPES[item.type] ?? item.type; + } + + if (typeof item.category === 'string') { + breadcrumb.category = item.category; + } + + if (typeof item.message === 'string') { + breadcrumb.message = item.message; + } + + if (typeof item.level === 'string') { + breadcrumb.level = item.level; + } + + if (typeof item.data === 'object' && item.data !== null && !Array.isArray(item.data)) { + breadcrumb.data = item.data as Record; + } + + breadcrumbs.push(breadcrumb); + }); + + return breadcrumbs.length > 0 ? breadcrumbs : undefined; +} diff --git a/workers/sentry/tests/converter.test.ts b/workers/sentry/tests/converter.test.ts index 17517150..71a961dd 100644 --- a/workers/sentry/tests/converter.test.ts +++ b/workers/sentry/tests/converter.test.ts @@ -1,5 +1,5 @@ -import { Event as SentryEvent } from '@sentry/core'; -import { composeTitle, composeBacktrace, composeContext, composeAddons, composeUserData } from '../src/utils/converter'; +import { Breadcrumb as SentryBreadcrumb, Event as SentryEvent } from '@sentry/core'; +import { composeTitle, composeBacktrace, composeBreadcrumbs, composeContext, composeAddons, composeUserData } from '../src/utils/converter'; describe('converter utils', () => { describe('composeTitle()', () => { @@ -430,4 +430,126 @@ describe('converter utils', () => { }); }); }); + describe('composeBreadcrumbs()', () => { + it('should convert breadcrumbs to Hawk format', () => { + const event: SentryEvent = { + breadcrumbs: [ + { + type: 'http', + category: 'fetch', + message: 'GET /api/profile', + level: 'info', + timestamp: 1718851200.123, + data: { + url: '/api/profile', + // eslint-disable-next-line @typescript-eslint/naming-convention + status_code: 200, + }, + }, + { + type: 'navigation', + category: 'navigation', + timestamp: 1718851201, + }, + ], + }; + + expect(composeBreadcrumbs(event)).toEqual([ + { + type: 'request', + category: 'fetch', + message: 'GET /api/profile', + level: 'info', + timestamp: 1718851200123, + data: { + url: '/api/profile', + // eslint-disable-next-line @typescript-eslint/naming-convention + status_code: 200, + }, + }, + { + type: 'navigation', + category: 'navigation', + timestamp: 1718851201000, + }, + ]); + }); + + it('should read breadcrumbs wrapped into "values" (Python, PHP SDKs)', () => { + const event: SentryEvent = { + breadcrumbs: { + values: [ { + type: 'default', + category: 'log', + message: 'User logged in', + timestamp: 1718851200, + } ], + } as unknown as SentryBreadcrumb[], + }; + + expect(composeBreadcrumbs(event)).toEqual([ { + type: 'default', + category: 'log', + message: 'User logged in', + timestamp: 1718851200000, + } ]); + }); + + it('should parse RFC 3339 timestamps', () => { + const event: SentryEvent = { + breadcrumbs: [ { + message: 'test', + timestamp: '2024-06-20T02:40:00.500Z' as unknown as number, + } ], + }; + + expect(composeBreadcrumbs(event)).toEqual([ { + message: 'test', + timestamp: 1718851200500, + } ]); + }); + + it('should use event timestamp for breadcrumbs without their own', () => { + const event: SentryEvent = { + timestamp: 1718851200, + breadcrumbs: [ { + message: 'test', + } ], + }; + + expect(composeBreadcrumbs(event)).toEqual([ { + message: 'test', + timestamp: 1718851200000, + } ]); + }); + + it('should skip breadcrumbs without any timestamp', () => { + const event: SentryEvent = { + breadcrumbs: [ { + message: 'test', + } ], + }; + + expect(composeBreadcrumbs(event)).toBeUndefined(); + }); + + it('should keep unknown types as is', () => { + const event: SentryEvent = { + breadcrumbs: [ { + type: 'custom', + timestamp: 1718851200, + } ], + }; + + expect(composeBreadcrumbs(event)).toEqual([ { + type: 'custom', + timestamp: 1718851200000, + } ]); + }); + + it('should handle missing breadcrumbs', () => { + expect(composeBreadcrumbs({})).toBeUndefined(); + expect(composeBreadcrumbs({ breadcrumbs: [] })).toBeUndefined(); + }); + }); }); diff --git a/workers/sentry/tests/index.test.ts b/workers/sentry/tests/index.test.ts index 6e8f34e2..ac531122 100644 --- a/workers/sentry/tests/index.test.ts +++ b/workers/sentry/tests/index.test.ts @@ -1,7 +1,7 @@ import SentryEventWorker from '../src'; import '../../../env-test'; import { mockedAmqpChannel } from '../../../jest.setup.js'; -import { EventEnvelope, serializeEnvelope, SeverityLevel } from '@sentry/core'; +import { Breadcrumb, EventEnvelope, serializeEnvelope, SeverityLevel } from '@sentry/core'; import { b64encode, base64toBuffer } from '../src/utils/base64'; import { CatcherMessagePayload, CatcherMessageType } from '@hawk.so/types'; import { SentryEventWorkerTask } from '../types/sentry-event-worker-task'; @@ -435,6 +435,61 @@ describe('SentryEventWorker', () => { }); }); + it('should extract breadcrumbs from "breadcrumbs" field if it is present', async () => { + const eventEnvelope: EventEnvelope = [ + { + /* eslint-disable @typescript-eslint/naming-convention */ + event_id: '123e4567-e89b-12d3-a456-426614174000', + sent_at: '2024-01-01T00:00:00.000Z', + /* eslint-enable @typescript-eslint/naming-convention */ + }, + [ + [ { type: 'event' }, { + level: 'error', + /** + * Python and PHP SDKs wrap breadcrumbs into { values: [...] } + */ + breadcrumbs: { + values: [ { + type: 'query', + category: 'db.sql.query', + message: 'select * from users where id = ?', + level: 'info', + timestamp: 1704067199.5, + } ], + } as unknown as Breadcrumb[], + } ], + ], + ]; + + await worker.handle({ + payload: { + envelope: b64encode(serializeEnvelope(eventEnvelope) as string), + }, + projectId: '123', + catcherType: 'external/sentry', + }); + + const addedTaskPayload = getAddTaskPayloadFromLastCall(); + + expect(addedTaskPayload).toMatchObject({ + payload: expect.objectContaining({ + breadcrumbs: [ { + type: 'request', + category: 'db.sql.query', + message: 'select * from users where id = ?', + level: 'info', + timestamp: 1704067199500, + } ], + }), + }); + expect(addedTaskPayload.payload.addons).toEqual({ + sentry: { + level: 'error', + }, + }); + }); + it('should extract addons from remaining fields', async () => { const eventEnvelope: EventEnvelope = [ {