Skip to content

Commit 9e4ba10

Browse files
authored
Implement heartbeat (#213)
* Implement heartbeat * Update CI pnpm/action-setup * Serialize config before creating the hash
1 parent f4493cb commit 9e4ba10

File tree

11 files changed

+101
-7
lines changed

11 files changed

+101
-7
lines changed

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ LOGGER_ENABLED=true
22
LOG_COLORIZE=true
33
LOG_FORMAT=pretty
44
LOG_LEVEL=info
5+
LOG_HEARTBEAT=true

.github/workflows/main.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ jobs:
2424
- name: Clone repo
2525
uses: actions/checkout@v4
2626
- name: Install pnpm
27-
uses: pnpm/action-setup@v2
27+
uses: pnpm/action-setup@v3
2828
with:
2929
version: 8.x
3030
- name: Setup Node
@@ -51,7 +51,7 @@ jobs:
5151
- name: Clone repo
5252
uses: actions/checkout@v4
5353
- name: Install pnpm
54-
uses: pnpm/action-setup@v2
54+
uses: pnpm/action-setup@v3
5555
with:
5656
version: 8.x
5757
- name: Setup Node

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,15 @@ Defines the minimum level of logs. Logs with smaller level (severity) will be si
111111

112112
Default: `info`.
113113

114+
### `LOG_HEARTBEAT` _(optional)_
115+
116+
Enables or disables the heartbeat log. The heartbeat log is a cryptographically secure log that is emitted every 60
117+
seconds to indicate that the service is running. The log includes useful information such as the configuration hash.
118+
Options:
119+
120+
- `true` - Enables the heartbeat log.
121+
- `false` - Disables the heartbeat log.
122+
114123
### Configuration files
115124

116125
Airseeker needs two configuration files, `airseeker.json` and `secrets.env`. All expressions of a form `${SECRET_NAME}`

src/config/index.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,16 @@ import dotenv from 'dotenv';
77
import { configSchema } from './schema';
88
import { interpolateSecrets, parseSecrets } from './utils';
99

10-
export const loadConfig = () => {
11-
const configPath = join(__dirname, '../../config');
12-
const rawSecrets = dotenv.parse(readFileSync(join(configPath, 'secrets.env'), 'utf8'));
10+
export const getConfigPath = () => join(__dirname, '../../config');
11+
12+
export const loadRawConfig = () => JSON.parse(readFileSync(join(getConfigPath(), 'airseeker.json'), 'utf8'));
1313

14+
export const loadRawSecrets = () => dotenv.parse(readFileSync(join(getConfigPath(), 'secrets.env'), 'utf8'));
15+
16+
export const loadConfig = () => {
1417
const goLoadConfig = goSync(() => {
15-
const rawConfig = JSON.parse(readFileSync(join(configPath, 'airseeker.json'), 'utf8'));
18+
const rawConfig = loadRawConfig();
19+
const rawSecrets = loadRawSecrets();
1620
const secrets = parseSecrets(rawSecrets);
1721
return configSchema.parse(interpolateSecrets(rawConfig, secrets));
1822
});

src/constants.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,7 @@ export const INT224_MIN = 2n ** 223n * -1n;
1111

1212
// Solidity type(int224).max
1313
export const INT224_MAX = 2n ** 223n - 1n;
14+
15+
// Intentionally making the message as constant so that it is not accidentally changed. Heartbeat logs subscribers will
16+
// listen for this exact message to parse the heartbeat.
17+
export const HEARTBEAT_LOG_MESSAGE = 'Sending heartbeat log.';

src/env/schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export const envConfigSchema = z
3939
})
4040
.default('info'),
4141
LOGGER_ENABLED: envBooleanSchema.default('true'),
42+
LOG_HEARTBEAT: envBooleanSchema.default('true'),
4243
})
4344
.strip(); // We parse from ENV variables of the process which has many variables that we don't care about
4445

src/heartbeat-loop/heartbeat-loop.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { createSha256Hash, serializePlainObject } from '@api3/commons';
2+
import { go } from '@api3/promise-utils';
3+
import { ethers } from 'ethers';
4+
5+
import { loadRawConfig } from '../config';
6+
import { HEARTBEAT_LOG_MESSAGE } from '../constants';
7+
import { logger } from '../logger';
8+
import { getState } from '../state';
9+
10+
import { heartbeatLogger } from './logger';
11+
12+
export const startHeartbeatLoop = () => {
13+
logger.info('Initiating heartbeat loop.');
14+
15+
setInterval(async () => {
16+
const goLogHeartbeat = await go(logHeartbeat);
17+
if (!goLogHeartbeat.success) logger.error('Failed to log heartbeat.', goLogHeartbeat.error);
18+
}, 1000 * 60); // Frequency is hardcoded to 1 minute.
19+
};
20+
21+
export interface HeartbeatPayload {
22+
currentTimestamp: string;
23+
configHash: string;
24+
signature: string;
25+
}
26+
27+
export const logHeartbeat = async () => {
28+
logger.debug('Creating heartbeat log.');
29+
30+
const rawConfig = loadRawConfig(); // We want to log the raw config, not the one with interpolated secrets.
31+
const configHash = createSha256Hash(serializePlainObject(rawConfig));
32+
const {
33+
config: { sponsorWalletMnemonic },
34+
} = getState();
35+
36+
logger.debug('Creating heartbeat payload.');
37+
const currentTimestamp = Math.floor(Date.now() / 1000).toString();
38+
const unsignedHeartbeatPayload = {
39+
currentTimestamp,
40+
configHash,
41+
};
42+
const sponsorWallet = ethers.Wallet.fromPhrase(sponsorWalletMnemonic);
43+
const signature = await signHeartbeat(sponsorWallet, unsignedHeartbeatPayload);
44+
const heartbeatPayload: HeartbeatPayload = { ...unsignedHeartbeatPayload, signature };
45+
46+
heartbeatLogger.info(HEARTBEAT_LOG_MESSAGE, heartbeatPayload);
47+
};
48+
49+
export const signHeartbeat = async (
50+
sponsorWallet: ethers.HDNodeWallet,
51+
unsignedHeartbeatPayload: Omit<HeartbeatPayload, 'signature'>
52+
) => {
53+
logger.debug('Signing heartbeat payload.');
54+
const messageToSign = ethers.getBytes(createSha256Hash(serializePlainObject(unsignedHeartbeatPayload)));
55+
return sponsorWallet.signMessage(messageToSign);
56+
};

src/heartbeat-loop/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './heartbeat-loop';

src/heartbeat-loop/logger.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { createLogger } from '@api3/commons';
2+
3+
import { loadEnv } from '../env/env';
4+
5+
// We need to load the environment variables before we can use the logger. Because we want the logger to always be
6+
// available, we load the environment variables as a side effect during the module import.
7+
const env = loadEnv();
8+
9+
export const heartbeatLogger = createLogger({
10+
colorize: env.LOG_COLORIZE,
11+
format: env.LOG_FORMAT,
12+
enabled: env.LOGGER_ENABLED,
13+
minLevel: 'info', // The heartbeat is sent with INFO severity.
14+
});

src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { loadConfig } from './config';
22
import { startDataFetcherLoop } from './data-fetcher-loop';
3+
import { loadEnv } from './env/env';
4+
import { startHeartbeatLoop } from './heartbeat-loop';
35
import { logger } from './logger';
46
import { setInitialState } from './state';
57
import { startUpdateFeedsLoops } from './update-feeds-loops';
@@ -12,6 +14,8 @@ function main() {
1214
logger.info('Starting Airseeker loops.');
1315
startDataFetcherLoop();
1416
void startUpdateFeedsLoops();
17+
const env = loadEnv();
18+
if (env.LOG_HEARTBEAT) startHeartbeatLoop();
1519
}
1620

1721
main();

0 commit comments

Comments
 (0)