Skip to content

Commit

Permalink
Merge pull request #167 from lidofinance/feature/si-1098-add-rpc-metrics
Browse files Browse the repository at this point in the history
feat: new metrics to collect eth_to addresses call counters
  • Loading branch information
itaven authored Dec 11, 2023
2 parents 1122ce3 + 4efca65 commit e2723c1
Show file tree
Hide file tree
Showing 5 changed files with 247 additions and 69 deletions.
1 change: 1 addition & 0 deletions config/metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ export const enum METRIC_NAMES {
REQUESTS_TOTAL = 'requests_total',
API_RESPONSE = 'api_response',
SUBGRAPHS_RESPONSE = 'subgraphs_response',
ETH_CALL_ADDRESS_TO = 'eth_call_address_to',
}
2 changes: 2 additions & 0 deletions pages/api/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
rateLimit,
responseTimeMetric,
defaultErrorHandler,
requestAddressMetric,
} from 'utilsApi';
import Metrics from 'utilsApi/metrics';
import { rpcUrls } from 'utilsApi/rpcUrls';
Expand Down Expand Up @@ -42,5 +43,6 @@ const rpc = rpcFactory({
export default wrapNextRequest([
rateLimit,
responseTimeMetric(Metrics.request.apiTimings, API_ROUTES.RPC),
requestAddressMetric(Metrics.request.ethCallToAddress),
defaultErrorHandler,
])(rpc);
93 changes: 93 additions & 0 deletions utilsApi/contractAddressesMetricsMap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import {
CHAINS,
TOKENS,
getTokenAddress,
getAggregatorAddress,
getWithdrawalQueueAddress,
} from '@lido-sdk/constants';
import {
StethAbiFactory,
WithdrawalQueueAbiFactory,
WstethAbiFactory,
} from '@lido-sdk/contracts';
import { dynamics, getAggregatorStEthUsdPriceFeedAddress } from 'config';
import { utils } from 'ethers';
import {
AggregatorAbi__factory,
AggregatorEthUsdPriceFeedAbi__factory,
} from 'generated';
import { invert, isNull, memoize, omitBy } from 'lodash';

export const CONTRACT_NAMES = {
stETH: 'stETH',
wstETH: 'wstETH',
WithdrawalQueue: 'WithdrawalQueue',
Aggregator: 'Aggregator',
AggregatorStEthUsdPriceFeed: 'AggregatorStEthUsdPriceFeed',
} as const;
export type CONTRACT_NAMES = keyof typeof CONTRACT_NAMES;

export const METRIC_CONTRACT_ABIS = {
[CONTRACT_NAMES.stETH]: StethAbiFactory.abi,
[CONTRACT_NAMES.wstETH]: WstethAbiFactory.abi,
[CONTRACT_NAMES.WithdrawalQueue]: WithdrawalQueueAbiFactory.abi,
[CONTRACT_NAMES.Aggregator]: AggregatorAbi__factory.abi,
[CONTRACT_NAMES.AggregatorStEthUsdPriceFeed]:
AggregatorEthUsdPriceFeedAbi__factory.abi,
} as const;

export const getMetricContractInterface = memoize(
(contractName: CONTRACT_NAMES) =>
new utils.Interface(METRIC_CONTRACT_ABIS[contractName]),
);

const getAddressOrNull = <
G extends (...args: any) => string,
A extends Parameters<G>,
>(
getter: G,
...args: A
) => {
try {
const address = getter(...args);
return address ? utils.getAddress(address) : null;
} catch (error) {
return null;
}
};

export const METRIC_CONTRACT_ADDRESSES = (
dynamics.supportedChains as CHAINS[]
).reduce(
(mapped, chainId) => {
const map = {
[CONTRACT_NAMES.stETH]: getAddressOrNull(
getTokenAddress,
chainId,
TOKENS.STETH,
),
[CONTRACT_NAMES.wstETH]: getAddressOrNull(
getTokenAddress,
chainId,
TOKENS.WSTETH,
),
[CONTRACT_NAMES.WithdrawalQueue]: getAddressOrNull(
getWithdrawalQueueAddress,
chainId,
),
[CONTRACT_NAMES.Aggregator]: getAddressOrNull(
getAggregatorAddress,
chainId,
),
[CONTRACT_NAMES.AggregatorStEthUsdPriceFeed]: getAddressOrNull(
getAggregatorStEthUsdPriceFeedAddress,
chainId,
),
};
return {
...mapped,
[chainId]: invert(omitBy(map, isNull)),
};
},
{} as Record<CHAINS, Record<`0x${string}`, CONTRACT_NAMES>>,
);
17 changes: 17 additions & 0 deletions utilsApi/metrics/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ export class RequestMetrics {
apiTimings: Histogram<'hostname' | 'route' | 'entity' | 'status'>;
apiTimingsExternal: Histogram<'hostname' | 'route' | 'entity' | 'status'>;
requestCounter: Counter<'route'>;
ethCallToAddress: Counter<'address' | 'referrer'>;

constructor(public registry: Registry) {
this.apiTimings = this.apiTimingsInit('internal');
this.apiTimingsExternal = this.apiTimingsInit('external');
this.requestCounter = this.requestsCounterInit();
this.ethCallToAddress = this.ethCallToAddressInit();
}

apiTimingsInit(postfix: string) {
Expand All @@ -36,4 +38,19 @@ export class RequestMetrics {
registers: [this.registry],
});
}

ethCallToAddressInit() {
return new Counter({
name: METRICS_PREFIX + METRIC_NAMES.ETH_CALL_ADDRESS_TO,
help: 'Addresses presented as "to" in eth_call requests',
labelNames: [
'address',
'referer',
'contractName',
'methodEncoded',
'methodDecoded',
],
registers: [this.registry],
});
}
}
203 changes: 134 additions & 69 deletions utilsApi/nextApiWrappers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Histogram } from 'prom-client';
import type { Histogram, Counter } from 'prom-client';
import { utils } from 'ethers';
import { getStatusLabel } from '@lidofinance/api-metrics';
import {
RequestWrapper,
Expand All @@ -13,6 +14,11 @@ import {
RATE_LIMIT,
RATE_LIMIT_TIME_FRAME,
} from 'config';
import {
getMetricContractInterface,
METRIC_CONTRACT_ADDRESSES,
} from './contractAddressesMetricsMap';
import { CHAINS } from '@lido-sdk/constants';

export enum HttpMethod {
GET = 'GET',
Expand Down Expand Up @@ -44,63 +50,122 @@ export type CorsWrapperType = {

export const cors =
({
origin = ['*'],
methods = [HttpMethod.GET],
allowedHeaders = ['*'],
credentials = false,
}: CorsWrapperType): RequestWrapper =>
async (req, res, next) => {
if (!req || !req.method) {
res.status(405);
throw new Error('Not HTTP method provided');
}
origin = ['*'],
methods = [HttpMethod.GET],
allowedHeaders = ['*'],
credentials = false,
}: CorsWrapperType): RequestWrapper =>
async (req, res, next) => {
if (!req || !req.method) {
res.status(405);
throw new Error('Not HTTP method provided');
}

res.setHeader('Access-Control-Allow-Credentials', String(credentials));
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Methods', methods.toString());
res.setHeader('Access-Control-Allow-Headers', allowedHeaders.toString());

if (req.method === HttpMethod.OPTIONS) {
// In preflight just need return a CORS headers
res.status(200).end();
return;
}

await next?.(req, res, next);
};

res.setHeader('Access-Control-Allow-Credentials', String(credentials));
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Methods', methods.toString());
res.setHeader('Access-Control-Allow-Headers', allowedHeaders.toString());
export const httpMethodGuard =
(methodAllowList: HttpMethod[]): RequestWrapper =>
async (req, res, next) => {
if (
!req ||
!req.method ||
!Object.values(methodAllowList).includes(req.method as HttpMethod)
) {
res.status(405);
throw new Error(`You can use only: ${methodAllowList.toString()}`);
}

await next?.(req, res, next);
};

if (req.method === HttpMethod.OPTIONS) {
// In preflight just need return a CORS headers
res.status(200).end();
return;
}
export const responseTimeMetric =
(metrics: Histogram<string>, route: string): RequestWrapper =>
async (req, res, next) => {
let status = '2xx';
const endMetric = metrics.startTimer({ route });

try {
await next?.(req, res, next);
};
status = getStatusLabel(res.statusCode);
} catch (error) {
status = getStatusLabel(res.statusCode);
// throw error up the stack
throw error;
} finally {
endMetric({ status });
}
};

const collectRequestAddressMetric = async ({
calls,
referer,
chainId,
metrics,
}: {
calls: any[];
referer: string;
chainId: CHAINS;
metrics: Counter<string>;
}) => {
const url = new URL(referer);
const urlWithoutQuery = `${url.origin}${url.pathname}`;
calls.forEach((call: any) => {
if (
typeof call === 'object' &&
call.method === 'eth_call' &&
call.params[0].to
) {
const { to, data } = call.params[0];
const address = utils.getAddress(to);
const contractName = METRIC_CONTRACT_ADDRESSES[chainId][address];
const methodEncoded = data?.slice(0, 10); // `0x` and 8 next symbols
const methodDecoded = contractName
? getMetricContractInterface(contractName)?.getFunction(methodEncoded)
?.name
: null;

metrics
.labels({
address,
referer: urlWithoutQuery,
contractName: contractName || 'N/A',
methodEncoded: methodEncoded || 'N/A',
methodDecoded: methodDecoded || 'N/A',
})
.inc(1);
}
});
};

export const httpMethodGuard =
(methodWhitelist: HttpMethod[]): RequestWrapper =>
async (req, res, next) => {
if (
!req ||
!req.method ||
!Object.values(methodWhitelist).includes(req.method as HttpMethod)
) {
res.status(405);
throw new Error(`You can use only: ${methodWhitelist.toString()}`);
}
export const requestAddressMetric =
(metrics: Counter<string>): RequestWrapper =>
async (req, res, next) => {
const referer = req.headers.referer as string;
const chainId = req.query.chainId as unknown as CHAINS;

await next?.(req, res, next);
};
if (req.body) {
void collectRequestAddressMetric({
calls: Array.isArray(req.body) ? req.body : [req.body],
referer,
chainId,
metrics,
}).catch(console.error);
}

export const responseTimeMetric =
(metrics: Histogram<string>, route: string): RequestWrapper =>
async (req, res, next) => {
let status = '2xx';
const endMetric = metrics.startTimer({ route });

try {
await next?.(req, res, next);
status = getStatusLabel(res.statusCode);
} catch (error) {
status = getStatusLabel(res.statusCode);
// throw error up the stack
throw error;
} finally {
endMetric({ status });
}
};
await next?.(req, res, next);
};

export const rateLimit = rateLimitWrapper({
rateLimit: RATE_LIMIT,
Expand All @@ -109,26 +174,26 @@ export const rateLimit = rateLimitWrapper({

export const nextDefaultErrorHandler =
(args?: DefaultErrorHandlerArgs): RequestWrapper =>
async (req, res, next) => {
const { errorMessage = DEFAULT_API_ERROR_MESSAGE, serverLogger: console } =
async (req, res, next) => {
const { errorMessage = DEFAULT_API_ERROR_MESSAGE, serverLogger: console } =
args || {};
try {
await next?.(req, res, next);
} catch (error) {
const isInnerError = res.statusCode === 200;
const status = isInnerError ? 500 : res.statusCode || 500;

if (error instanceof Error) {
const serverError = 'status' in error && (error.status as number);
console?.error(extractErrorMessage(error, errorMessage));
res
.status(serverError || status)
.json({ message: extractErrorMessage(error, errorMessage) });
} else {
res.status(status).json({ message: errorMessage });
}
try {
await next?.(req, res, next);
} catch (error) {
const isInnerError = res.statusCode === 200;
const status = isInnerError ? 500 : res.statusCode || 500;

if (error instanceof Error) {
const serverError = 'status' in error && (error.status as number);
console?.error(extractErrorMessage(error, errorMessage));
res
.status(serverError || status)
.json({ message: extractErrorMessage(error, errorMessage) });
} else {
res.status(status).json({ message: errorMessage });
}
};
}
};

export const defaultErrorHandler = nextDefaultErrorHandler({
serverLogger: console,
Expand Down

0 comments on commit e2723c1

Please sign in to comment.