Skip to content
Closed
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
6 changes: 5 additions & 1 deletion .github/workflows/integration-tests.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
name: Run integration tests on push

on:
- push
push:
branches-ignore:
- main
- master
- prod

jobs:
tests:
Expand Down
6 changes: 5 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
name: Run Jest tests on push

on:
- push
push:
branches-ignore:
- main
- master
- prod

jobs:
build:
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hawk.api",
"version": "1.5.6",
"version": "1.5.10",
"main": "index.ts",
"license": "BUSL-1.1",
"scripts": {
Expand Down
75 changes: 52 additions & 23 deletions src/billing/cloudpayments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ export default class CloudPaymentsWebhooks {
return;
}

telegram.sendMessage(`🤗 [Billing / Check] All checks passed successfully «${workspace.name}»`, TelegramBotURLs.Money)
telegram.sendMessage(`🤗 [Billing / Check] All checks passed successfully ${this.formatWorkspaceForTelegram(workspace)}`, TelegramBotURLs.Money)
.catch(e => console.error('Error while sending message to Telegram: ' + e));

HawkCatcher.send(new Error('[Billing / Check] All checks passed successfully'), body as any);
Expand Down Expand Up @@ -425,7 +425,7 @@ export default class CloudPaymentsWebhooks {

this.handleSendingToTelegramError(telegram.sendMessage(`✅ [Billing / Pay] Card linked

workspace id: ${workspace._id}
${this.formatWorkspaceForTelegram(workspace)}
date of operation: ${body.DateTime}
first payment date: ${data.cloudPayments?.recurrent.startDate}
card link charge: ${+body.Amount} ${body.Currency}
Expand All @@ -451,14 +451,14 @@ plan monthly charge: ${data.cloudPayments?.recurrent.amount} ${body.Currency}`

amount: ${+body.Amount} ${body.Currency}
next payment date: ${data.cloudPayments?.recurrent.startDate}
workspace id: ${workspace._id}
${this.formatWorkspaceForTelegram(workspace)}
date of operation: ${body.DateTime}
subscription id: ${body.SubscriptionId}`;
} else {
messageText = `✅ [Billing / Pay] New Recurrent payment

amount: ${+body.Amount} ${body.Currency}
workspace id: ${workspace._id}
${this.formatWorkspaceForTelegram(workspace)}
date of operation: ${body.DateTime}
subscription id: ${body.SubscriptionId}`;
}
Expand Down Expand Up @@ -491,10 +491,12 @@ subscription id: ${body.SubscriptionId}`;

console.log('💎 CloudPayments /fail request', body);

const failReasonLine = `reason: ${body.Reason} (${body.ReasonCode})`;

try {
data = await this.getDataFromRequest(req);
} catch (e) {
this.sendError(res, FailCodes.SUCCESS, `[Billing / Fail] Invalid request`, body);
this.sendError(res, FailCodes.SUCCESS, `[Billing / Fail] Invalid request\n${failReasonLine}`, body);

return;
}
Expand All @@ -508,7 +510,7 @@ subscription id: ${body.SubscriptionId}`;
*/

if (!data.workspaceId || !data.userId || !data.tariffPlanId) {
this.sendError(res, FailCodes.SUCCESS, `[Billing / Fail] No workspace or user id or plan id in request body`, body);
this.sendError(res, FailCodes.SUCCESS, `[Billing / Fail] No workspace or user id or plan id in request body\n${failReasonLine}`, body);

return;
}
Expand All @@ -520,7 +522,7 @@ subscription id: ${body.SubscriptionId}`;
} catch (e) {
const error = e as Error;

this.sendError(res, FailCodes.SUCCESS, `[Billing / Fail] ${error.toString()}`, body);
this.sendError(res, FailCodes.SUCCESS, `[Billing / Fail] ${error.toString()}\n${failReasonLine}`, body);

return;
}
Expand All @@ -530,7 +532,7 @@ subscription id: ${body.SubscriptionId}`;
} catch (e) {
const error = e as Error;

this.sendError(res, FailCodes.SUCCESS, `[Billing / Fail] Can't update business operation status ${error.toString()}`, body);
this.sendError(res, FailCodes.SUCCESS, `[Billing / Fail] Can't update business operation status ${error.toString()}\n${failReasonLine}`, body);

return;
}
Expand All @@ -548,12 +550,18 @@ subscription id: ${body.SubscriptionId}`;
} catch (e) {
const error = e as Error;

this.sendError(res, FailCodes.SUCCESS, `[Billing / Fail] Error while sending notification to the user ${error.toString()}`, body);
this.sendError(res, FailCodes.SUCCESS, `[Billing / Fail] Error while sending notification to the user ${error.toString()}\n${failReasonLine}`, body);

return;
}

this.handleSendingToTelegramError(telegram.sendMessage(`❌ [Billing / Fail] Transaction failed for «${workspace.name}»`, TelegramBotURLs.Money));
this.handleSendingToTelegramError(telegram.sendMessage(`❌ [Billing / Fail] Transaction failed for ${this.formatWorkspaceForTelegram(workspace)}

amount: ${+body.Amount} ${body.Currency}
${failReasonLine}
transaction id: ${body.TransactionId}
subscription id: ${body.SubscriptionId || 'none'}`
, TelegramBotURLs.Money));

HawkCatcher.send(new Error('[Billing / Fail] Transaction failed'), body as any);

Expand All @@ -577,11 +585,21 @@ subscription id: ${body.SubscriptionId}`;

const emoji = [SubscriptionStatus.CANCELLED, SubscriptionStatus.REJECTED].includes(body.Status) ? '❌' : '✅';

let workspace: WorkspaceModel | null = null;
let workspaceLookupError: Error | null = null;

try {
workspace = await context.factories.workspacesFactory.findBySubscriptionId(body.Id);
} catch (e) {
workspaceLookupError = e as Error;
}

this.handleSendingToTelegramError(telegram.sendMessage(`${emoji} [Billing / Recurrent] New recurrent event

amount: ${+body.Amount} ${body.Currency}
next payment date: ${body.NextTransactionDate}
workspace id: ${body.AccountId}
${this.formatWorkspaceForTelegram(workspace)}
user id: ${body.AccountId}
subscription id: ${body.Id}
status: ${body.Status}`
, TelegramBotURLs.Money));
Expand All @@ -591,18 +609,8 @@ status: ${body.Status}`
switch (body.Status) {
case SubscriptionStatus.CANCELLED:
case SubscriptionStatus.REJECTED: {
let workspace;

try {
/**
* If there is a workspace with subscription id then subscription was cancelled via CloudPayments admin panel (or other no garage way)
* We need to remove subscription id from workspace
*/
workspace = await context.factories.workspacesFactory.findBySubscriptionId(body.Id);
} catch (e) {
const error = e as Error;

this.sendError(res, RecurrentCodes.SUCCESS, `[Billing / Recurrent] Can't get data from database: ${error.toString()}`, {
if (workspaceLookupError) {
this.sendError(res, RecurrentCodes.SUCCESS, `[Billing / Recurrent] Can't get data from database: ${workspaceLookupError.toString()}`, {
body,
workspace,
});
Expand All @@ -624,6 +632,10 @@ status: ${body.Status}`
}

try {
/**
* Subscription was cancelled via CloudPayments admin panel (or other no-garage way).
* Remove subscription id from workspace.
*/
await workspace.setSubscriptionId(null);
} catch (e) {
const error = e as Error;
Expand All @@ -641,6 +653,23 @@ status: ${body.Status}`
} as RecurrentResponse);
}

/**
* Formats workspace identity for Telegram money notifications
*
* @param workspace - workspace model or null when it was not found
*/
private formatWorkspaceForTelegram(workspace: Pick<WorkspaceModel, '_id' | 'name'> | null): string {
if (!workspace) {
return 'workspace: unknown';
}

if (workspace.name) {
return `workspace: «${workspace.name}» (${workspace._id})`;
}

return `workspace id: ${workspace._id}`;
}

/**
* Get workspace by workspace id
*
Expand Down
15 changes: 15 additions & 0 deletions src/metrics/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import client from 'prom-client';
import { ApolloServerPlugin, GraphQLRequestContext, GraphQLRequestListener } from 'apollo-server-plugin-base';
import { GraphQLError } from 'graphql';
import HawkCatcher from '@hawk.so/nodejs';
import { notifySlowOperation } from './slowOperationAlert';
import { buildGraphqlRequestContext, formatGraphqlErrorsForAlert } from './graphqlRequestDetails';
/**
* GraphQL operation duration histogram
* Tracks GraphQL operation duration by operation name and type
Expand Down Expand Up @@ -93,6 +95,19 @@ export const graphqlMetricsPlugin: ApolloServerPlugin = {
},
});

notifySlowOperation(
`Slow GraphQL operation: ${operationType} ${operationName}`,
durationMs,
{
operationType,
operationName,
...buildGraphqlRequestContext(ctx),
...(hasErrors && {
errors: formatGraphqlErrorsForAlert(ctx.errors!),
}),
}
);

// Track errors if any
if (hasErrors) {
ctx.errors!.forEach((error: GraphQLError) => {
Expand Down
158 changes: 158 additions & 0 deletions src/metrics/graphqlRequestDetails.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { GraphQLRequestContext } from 'apollo-server-plugin-base';
import { GraphQLError } from 'graphql';
import { ResolverContextBase } from '../types/graphql';
import { truncateText } from './slowOperationAlert';

const MAX_ALERT_ERRORS = 10;
const MAX_ALERT_ERRORS_LENGTH = 1200;

const SENSITIVE_VARIABLE_KEYS = new Set([
'password',
'token',
'accesstoken',
'refreshtoken',
'secret',
'authorization',
]);

const HIGHLIGHTED_VARIABLE_KEYS = new Set([
'projectid',
'workspaceid',
'eventid',
'originaleventid',
'release',
'search',
'assignee',
'cursor',
]);

/**
* Redact sensitive GraphQL variables before sending alerts.
*
* @param value - variable value
* @param key - variable key
* @returns sanitized value
*/
function sanitizeVariableValue(value: unknown, key: string): unknown {
if (SENSITIVE_VARIABLE_KEYS.has(key.toLowerCase())) {
return '[redacted]';
}

if (Array.isArray(value)) {
return value.map((item, index) => sanitizeVariableValue(item, `${key}[${index}]`));
}

if (value && typeof value === 'object') {
// eslint-disable-next-line @typescript-eslint/no-use-before-define
return sanitizeVariables(value as Record<string, unknown>);
}

return value;
}

/**
* Redact sensitive GraphQL variables before sending alerts.
*
* @param variables - GraphQL request variables
* @returns sanitized variables
*/
function sanitizeVariables(
variables: Record<string, unknown> | null | undefined
): Record<string, unknown> {
/**
* Null / non-object values are treated as empty — many clients send
* `variables: null` for operations without variables, and arrays are not a
* valid GraphQL variables map.
*/
if (variables == null || typeof variables !== 'object' || Array.isArray(variables)) {
return {};
}

return Object.fromEntries(
Object.entries(variables).map(([key, value]) => [key, sanitizeVariableValue(value, key)])
);
}

/**
* Extract useful identifiers from nested GraphQL variables.
*
* @param value - variable value
* @param prefix - nested path prefix
* @param result - accumulator for extracted ids
* @returns extracted identifiers
*/
function collectHighlightedIds(
value: unknown,
prefix = '',
result: Record<string, string | number | boolean> = {}
): Record<string, string | number | boolean> {
if (!value || typeof value !== 'object') {
return result;
}

for (const [key, nestedValue] of Object.entries(value as Record<string, unknown>)) {
const path = prefix ? `${prefix}.${key}` : key;

if (
HIGHLIGHTED_VARIABLE_KEYS.has(key.toLowerCase()) &&
(typeof nestedValue === 'string' || typeof nestedValue === 'number' || typeof nestedValue === 'boolean')
) {
result[path] = nestedValue;
continue;
}

if (nestedValue && typeof nestedValue === 'object' && !Array.isArray(nestedValue)) {
collectHighlightedIds(nestedValue, path, result);
}
}

return result;
}

/**
* Flatten GraphQL errors into a capped string for Hawk alert context.
* sanitizeContext() only truncates top-level strings, not values nested in arrays.
* Reserves space for an omitted-count suffix and truncateText()'s ellipsis.
*
* @param errors - GraphQL errors from the request
* @returns flattened and truncated error messages
*/
export function formatGraphqlErrorsForAlert(errors: readonly GraphQLError[]): string {
const messages = errors.slice(0, MAX_ALERT_ERRORS).map((error) => error.message);
const omittedCount = errors.length - messages.length;
const omittedSuffix = omittedCount > 0 ? `; …(+${omittedCount} more)` : '';
const maxMessagesLength = Math.max(0, MAX_ALERT_ERRORS_LENGTH - omittedSuffix.length - 1);

return `${truncateText(messages.join('; '), maxMessagesLength)}${omittedSuffix}`;
}

/**
* Build request context for slow GraphQL operation alerts.
*
* @param ctx - GraphQL request context
* @returns alert context
*/
export function buildGraphqlRequestContext(ctx: GraphQLRequestContext): Record<string, unknown> {
const context = ctx.context as ResolverContextBase | undefined;
const variables = sanitizeVariables(
ctx.request.variables as Record<string, unknown> | null | undefined
);
const highlightedIds = collectHighlightedIds(variables);
const alertContext: Record<string, unknown> = {};

if (context?.user?.id) {
alertContext.userId = context.user.id;
}

if (Object.keys(highlightedIds).length > 0) {
alertContext.ids = highlightedIds;
}

const variablesJson = JSON.stringify(variables);

if (variablesJson && variablesJson !== '{}') {
alertContext.variables = truncateText(variablesJson, 1200);
}

return alertContext;
}
Loading
Loading