Skip to content
Open
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
2 changes: 1 addition & 1 deletion workers/sentry/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
7 changes: 6 additions & 1 deletion workers/sentry/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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'> = {
Expand All @@ -345,6 +346,10 @@ export default class SentryEventWorker extends Worker {
event.user = user;
}

if (breadcrumbs) {
event.breadcrumbs = breadcrumbs;
}

if (addons) {
event.addons = {
sentry: addons,
Expand Down
102 changes: 100 additions & 2 deletions workers/sentry/src/utils/converter.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -210,3 +210,101 @@ export function composeUserData(eventPayload: SentryEvent): EventData<DefaultAdd

return undefined;
}

/**
* Sentry breadcrumb types mapped to the types Hawk renders.
* Types missing here are passed as is and displayed as 'default'.
*
* @see https://develop.sentry.dev/sdk/data-model/event-payloads/breadcrumbs/#breadcrumb-types
*/
const SENTRY_TO_HAWK_BREADCRUMB_TYPES: Record<string, string> = {
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<string, JsonNode>;
}

breadcrumbs.push(breadcrumb);
});

return breadcrumbs.length > 0 ? breadcrumbs : undefined;
}
126 changes: 124 additions & 2 deletions workers/sentry/tests/converter.test.ts
Original file line number Diff line number Diff line change
@@ -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()', () => {
Expand Down Expand Up @@ -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();
});
});
});
57 changes: 56 additions & 1 deletion workers/sentry/tests/index.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 = [
{
Expand Down