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 package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "hawk.workers",
"private": true,
"version": "0.1.3",
"version": "0.1.4",
"description": "Hawk workers",
"repository": "git@github.com:codex-team/hawk.workers.git",
"license": "BUSL-1.1",
Expand Down
16 changes: 12 additions & 4 deletions runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,6 @@ import * as dotenv from 'dotenv';

dotenv.config();

if (process.env.HAWK_CATCHER_TOKEN) {
HawkCatcher.init(process.env.HAWK_CATCHER_TOKEN);
}

type WorkerConstructor = new () => Worker;

const BEGINNING_OF_ARGS = 2;
Expand All @@ -27,6 +23,18 @@ const BEGINNING_OF_ARGS = 2;
*/
const workerNames = process.argv.slice(BEGINNING_OF_ARGS);

/**
* Initialize HawkCatcher
*/
if (process.env.HAWK_CATCHER_TOKEN) {
HawkCatcher.init({
token: process.env.HAWK_CATCHER_TOKEN,
context: {
workerTypes: workerNames.join(","),
}
});
}

/**
* Workers dispatcher.
* Load, run and finish workers.
Expand Down
43 changes: 15 additions & 28 deletions workers/grouper/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import TimeMs from '../../../lib/utils/time';
import DataFilter from './data-filter';
import RedisHelper from './redisHelper';
import { computeDelta } from './utils/repetitionDiff';
import { bucketTimestampMs } from './utils/bucketTimestamp';
import { rightTrim } from '../../../lib/utils/string';
import { hasValue } from '../../../lib/utils/hasValue';

Expand All @@ -48,6 +49,11 @@ const CACHE_CLEANUP_INTERVAL_SECONDS = 30;
*/
const DB_DUPLICATE_KEY_ERROR = '11000';

/**
* Retention period for daily Redis TimeSeries metrics in days
*/
const DAILY_METRICS_RETENTION_DAYS = 90;

/**
* Maximum length for backtrace code line or title
*/
Expand Down Expand Up @@ -343,37 +349,18 @@ export default class GrouperWorker extends Worker {
};

const series = [
{
key: minutelyKey,
label: 'minutely',
retentionMs: TimeMs.DAY,
},
{
key: hourlyKey,
label: 'hourly',
retentionMs: TimeMs.WEEK,
},
{
key: dailyKey,
label: 'daily',
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
retentionMs: 90 * TimeMs.DAY,
},
{ key: minutelyKey, label: 'minutely', retentionMs: TimeMs.DAY, timestampMs: bucketTimestampMs('minutely') },
{ key: hourlyKey, label: 'hourly', retentionMs: TimeMs.WEEK, timestampMs: bucketTimestampMs('hourly') },
{ key: dailyKey, label: 'daily', retentionMs: DAILY_METRICS_RETENTION_DAYS * TimeMs.DAY, timestampMs: bucketTimestampMs('daily') },
];

const operations = series.map(({ key, label, retentionMs }) => ({
label,
promise: this.redis.safeTsAdd(key, 1, labels, retentionMs),
}));

const results = await Promise.allSettled(operations.map((op) => op.promise));

results.forEach((result, index) => {
if (result.status === 'rejected') {
const { label } = operations[index];
this.logger.error(`Failed to add ${label} TS for ${metricType}`, result.reason);
for (const { key, label, retentionMs, timestampMs } of series) {
try {
await this.redis.safeTsAdd(key, 1, labels, retentionMs, timestampMs);
} catch (error) {
this.logger.error(`Failed to add ${label} TS for ${metricType}`, error);
}
});
}
}

/**
Expand Down
38 changes: 14 additions & 24 deletions workers/grouper/src/redisHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,17 +193,11 @@ export default class RedisHelper {
): Promise<void> {
const timestamp = Date.now();

try {
await this.tsIncrBy(key, value, timestamp, labels);
} catch (error) {
if (error instanceof Error && error.message.includes('TSDB: key does not exist')) {
this.logger.warn(`TS key ${key} does not exist, creating it...`);
await this.tsCreateIfNotExists(key, labels, retentionMs);
await this.tsIncrBy(key, value, timestamp, labels);
} else {
throw error;
}
}
/**
* Create key if not exists — then call increment
*/
await this.tsCreateIfNotExists(key, labels, retentionMs);
await this.tsIncrBy(key, value, timestamp, labels);
}

/**
Expand Down Expand Up @@ -243,26 +237,22 @@ export default class RedisHelper {
* @param value - value to add
* @param labels - labels to attach to the time series
* @param retentionMs - optional retention in milliseconds
* @param timestampMs - timestamp in milliseconds; defaults to current time
*/
public async safeTsAdd(
key: string,
value: number,
labels: Record<string, string>,
retentionMs = 0
retentionMs = 0,
timestampMs = 0
): Promise<void> {
const timestamp = Date.now();
const timestamp = timestampMs === 0 ? Date.now() : timestampMs;

try {
await this.tsAdd(key, value, timestamp, labels);
} catch (error) {
if (error instanceof Error && error.message.includes('TSDB: key does not exist')) {
this.logger.warn(`TS key ${key} does not exist, creating it...`);
await this.tsCreateIfNotExists(key, labels, retentionMs);
await this.tsAdd(key, value, timestamp, labels);
} else {
throw error;
}
}
/**
* Create key if not exists — then call increment
*/
await this.tsCreateIfNotExists(key, labels, retentionMs);
await this.tsAdd(key, value, timestamp, labels);
}

/**
Expand Down
17 changes: 17 additions & 0 deletions workers/grouper/src/utils/bucketTimestamp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import TimeMs from '../../../../lib/utils/time';

/**
* Returns the current time truncated to the start of the given granularity
* bucket in milliseconds (UTC). All events within the same bucket share one
* timestamp so ON_DUPLICATE SUM accumulates them into a single sample.
*
* @param granularity - time granularity level
* @param now - current timestamp in ms, defaults to Date.now()
*/
export function bucketTimestampMs(granularity: 'minutely' | 'hourly' | 'daily', now = Date.now()): number {
switch (granularity) {
case 'hourly': return now - (now % TimeMs.HOUR);
case 'daily': return now - (now % TimeMs.DAY);
default: return now - (now % TimeMs.MINUTE); // minutely
}
}
65 changes: 65 additions & 0 deletions workers/grouper/tests/bucketTimestamp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import '../../../env-test';
import { bucketTimestampMs } from '../src/utils/bucketTimestamp';

describe('bucketTimestampMs', () => {
/**
* 2026-04-14T15:37:42.500Z
* minute start: 2026-04-14T15:37:00.000Z
* hour start: 2026-04-14T15:00:00.000Z
* day start: 2026-04-14T00:00:00.000Z
*/
const now = new Date('2026-04-14T15:37:42.500Z').getTime();

it('truncates to the start of the current minute', () => {
const expected = new Date('2026-04-14T15:37:00.000Z').getTime();

expect(bucketTimestampMs('minutely', now)).toBe(expected);
});

it('truncates to the start of the current hour', () => {
const expected = new Date('2026-04-14T15:00:00.000Z').getTime();

expect(bucketTimestampMs('hourly', now)).toBe(expected);
});

it('truncates to the start of the current day (UTC midnight)', () => {
const expected = new Date('2026-04-14T00:00:00.000Z').getTime();

expect(bucketTimestampMs('daily', now)).toBe(expected);
});

it('returns the same value for two calls within the same minute', () => {
const t1 = new Date('2026-04-14T15:37:00.000Z').getTime();
const t2 = new Date('2026-04-14T15:37:59.999Z').getTime();

expect(bucketTimestampMs('minutely', t1)).toBe(bucketTimestampMs('minutely', t2));
});

it('returns different values for two calls in different minutes', () => {
const t1 = new Date('2026-04-14T15:37:59.999Z').getTime();
const t2 = new Date('2026-04-14T15:38:00.000Z').getTime();

expect(bucketTimestampMs('minutely', t1)).not.toBe(bucketTimestampMs('minutely', t2));
});

it('returns the same value for two calls within the same hour', () => {
const t1 = new Date('2026-04-14T15:00:00.000Z').getTime();
const t2 = new Date('2026-04-14T15:59:59.999Z').getTime();

expect(bucketTimestampMs('hourly', t1)).toBe(bucketTimestampMs('hourly', t2));
});

it('returns the same value for two calls within the same day', () => {
const t1 = new Date('2026-04-14T00:00:00.000Z').getTime();
const t2 = new Date('2026-04-14T23:59:59.999Z').getTime();

expect(bucketTimestampMs('daily', t1)).toBe(bucketTimestampMs('daily', t2));
});

it('returns different values for two calls on different days', () => {
const t1 = new Date('2026-04-14T23:59:59.999Z').getTime();
const t2 = new Date('2026-04-15T00:00:00.000Z').getTime();

expect(bucketTimestampMs('daily', t1)).not.toBe(bucketTimestampMs('daily', t2));
});
});
9 changes: 6 additions & 3 deletions workers/grouper/tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -763,21 +763,24 @@ describe('GrouperWorker', () => {
`ts:project-events-accepted:${projectIdMock}:minutely`,
1,
expectedLabels,
TimeMs.DAY
TimeMs.DAY,
expect.any(Number),
);
expect(safeTsAddSpy).toHaveBeenNthCalledWith(
2,
`ts:project-events-accepted:${projectIdMock}:hourly`,
1,
expectedLabels,
TimeMs.WEEK
TimeMs.WEEK,
expect.any(Number),
);
expect(safeTsAddSpy).toHaveBeenNthCalledWith(
3,
`ts:project-events-accepted:${projectIdMock}:daily`,
1,
expectedLabels,
90 * TimeMs.DAY
90 * TimeMs.DAY,
expect.any(Number),
);
} finally {
safeTsAddSpy.mockRestore();
Expand Down
3 changes: 2 additions & 1 deletion workers/loop/src/templates/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ function renderBacktrace(event: GroupedEventDBScheme): string {
export default function render(tplData: EventsTemplateVariables): string {
const eventInfo = tplData.events[0] as TemplateEventData;
const event = eventInfo.event;
const eventURL = tplData.host + '/project/' + tplData.project._id + '/event/' + event._id + '/';
const repetitionId = eventInfo.repetitionId;
const eventURL = tplData.host + '/project/' + tplData.project._id + '/event/' + event._id + '/' + (repetitionId ? repetitionId + '/overview' : '');
let location = 'Неизвестное место';

if (event.payload.backtrace && event.payload.backtrace.length > 0) {
Expand Down
48 changes: 48 additions & 0 deletions workers/loop/tests/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,54 @@ describe('LoopProvider', () => {
expect(message).toBeDefined();
});

/**
* Event URL should include repetitionId when provided
*/
describe('event URL contains correct repetitionId', () => {
const eventId = new ObjectId('5d206f7f9aaf7c0071d64597');
const projectId = new ObjectId('5d206f7f9aaf7c0071d64596');
const host = 'https://garage.hawk.so';

const basePayload = {
events: [ {
event: {
_id: eventId,
totalCount: 1,
timestamp: Date.now(),
payload: { title: 'Err', backtrace: [] },
} as DecodedGroupedEvent,
daysRepeated: 1,
newCount: 1,
} ],
period: 60,
host,
hostOfStatic: '',
project: {
_id: projectId,
token: 'tok',
name: 'P',
workspaceId: projectId,
uidAdded: projectId,
notifications: [],
} as ProjectDBScheme,
};

it('should include repetitionId and /overview in URL when repetitionId is set', () => {
const repetitionId = '5d206f7f9aaf7c0071d64599';
const payload = { ...basePayload, events: [ { ...basePayload.events[0], repetitionId } ] };
const message = templates.EventTpl(payload);

expect(message).toContain(`/event/${eventId}/${repetitionId}/overview`);
});

it('should omit repetitionId from URL when repetitionId is not set', () => {
const message = templates.EventTpl(basePayload);

expect(message).toContain(`/event/${eventId}/`);
expect(message).not.toContain('/overview');
});
});

/**
* Check that rendering of a several events message works without errors
*/
Expand Down
8 changes: 7 additions & 1 deletion workers/sentry/src/utils/converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,13 @@ function flattenObject(obj: unknown, prefix = ''): string[] {
* @param eventPayload - Sentry event payload
*/
export function composeTitle(eventPayload: SentryEvent): string {
return `${eventPayload.exception?.values?.[0]?.type || 'Unknown'}: ${eventPayload.exception?.values?.[0]?.value || ''}`;
const exception = eventPayload.exception?.values?.[0];

if (exception) {
return `${exception.type || 'Unknown'}: ${exception.value || ''}`;
}

return eventPayload.message || 'Unknown: ';
}

/**
Expand Down
22 changes: 22 additions & 0 deletions workers/sentry/tests/converter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,28 @@ describe('converter utils', () => {

expect(composeTitle(event)).toBe('Unknown: ');
});

it('should compose title from message if exception is missing', () => {
const event: SentryEvent = {
message: 'message'
};

expect(composeTitle(event)).toBe('message');
});

it('should compose title from exception type and value even if message is present', () => {
const event: SentryEvent = {
exception: {
values: [ {
type: 'Error',
value: 'Something went wrong',
} ],
},
message: 'message'
};

expect(composeTitle(event)).toBe('Error: Something went wrong');
});
});

describe('composeBacktrace()', () => {
Expand Down
2 changes: 1 addition & 1 deletion workers/sentry/tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,7 @@ describe('SentryEventWorker', () => {
},
},
catcherVersion: '1.0.1',
title: 'Unknown: ',
title: 'Test timestamp',
type: 'error',
},
}));
Expand Down
Loading
Loading