From 0f58b66901123a3c00acdeab94920c560e845bb8 Mon Sep 17 00:00:00 2001 From: Matthew Little Date: Tue, 5 Nov 2024 15:15:47 +0100 Subject: [PATCH 1/4] feat: add signer last_seen field --- src/api/routes/cycle.ts | 16 ++++++++--- src/api/schemas.ts | 6 +++++ src/pg/pg-store.ts | 26 ++++++++++++++++-- tests/db/endpoints.test.ts | 54 +++++++++++++++++++++++++++++++++----- 4 files changed, 89 insertions(+), 13 deletions(-) diff --git a/src/api/routes/cycle.ts b/src/api/routes/cycle.ts index fe5d375..7db8490 100644 --- a/src/api/routes/cycle.ts +++ b/src/api/routes/cycle.ts @@ -1,7 +1,12 @@ import { Type, TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; import { FastifyPluginCallback } from 'fastify'; import { Server } from 'http'; -import { CycleSignerResponseSchema, CycleSignersResponseSchema } from '../schemas'; +import { + CycleSigner, + CycleSignerResponse, + CycleSignerResponseSchema, + CycleSignersResponseSchema, +} from '../schemas'; import { parseTime } from '../../helpers'; import { InvalidRequestError } from '../errors'; @@ -68,7 +73,7 @@ export const CycleRoutes: FastifyPluginCallback< }); const formatted = results.map(result => { - return { + const cycleSinger: CycleSigner = { signer_key: result.signer_key, weight: result.weight, weight_percentage: result.weight_percentage, @@ -79,7 +84,9 @@ export const CycleRoutes: FastifyPluginCallback< proposals_rejected_count: result.proposals_rejected_count, proposals_missed_count: result.proposals_missed_count, average_response_time_ms: result.average_response_time_ms, + last_seen: result.last_block_response_time?.toISOString() ?? null, }; + return cycleSinger; }); return { @@ -125,8 +132,7 @@ export const CycleRoutes: FastifyPluginCallback< error: 'Signer not found', }); } - - return { + const cycleSigner: CycleSignerResponse = { signer_key: signer.signer_key, weight: signer.weight, weight_percentage: signer.weight_percentage, @@ -137,7 +143,9 @@ export const CycleRoutes: FastifyPluginCallback< proposals_rejected_count: signer.proposals_rejected_count, proposals_missed_count: signer.proposals_missed_count, average_response_time_ms: signer.average_response_time_ms, + last_seen: signer.last_block_response_time?.toISOString() ?? null, }; + return cycleSigner; }); await reply.send(result); } diff --git a/src/api/schemas.ts b/src/api/schemas.ts index a0d02ee..c38f18a 100644 --- a/src/api/schemas.ts +++ b/src/api/schemas.ts @@ -159,6 +159,12 @@ export const CycleSignerSchema = Type.Object({ description: 'Time duration (in milliseconds) taken to submit responses to block proposals (tracked best effort)', }), + last_seen: Type.Union([ + Type.String({ + description: 'ISO timestamp of the last time a message from this signer was seen', + }), + Type.Null(), + ]), // TODO: implement these nice-to-have fields /* mined_blocks_accepted_included_count: Type.Integer({ diff --git a/src/pg/pg-store.ts b/src/pg/pg-store.ts index 073b0cf..823ddd5 100644 --- a/src/pg/pg-store.ts +++ b/src/pg/pg-store.ts @@ -299,6 +299,7 @@ export class PgStore extends BasePgStore { proposals_rejected_count: number; proposals_missed_count: number; average_response_time_ms: number; + last_block_response_time: Date | null; }[] >` WITH signer_data AS ( @@ -333,6 +334,14 @@ export class PgStore extends BasePgStore { FROM block_responses br JOIN proposal_data pd ON br.signer_sighash = pd.block_hash -- Only responses linked to selected proposals ), + latest_response_data AS ( + -- Find the latest response time for each signer + SELECT + signer_key, + MAX(received_at) AS last_block_response_time + FROM response_data + GROUP BY signer_key + ), signer_proposal_data AS ( -- Cross join signers with proposals and left join filtered responses SELECT @@ -377,12 +386,15 @@ export class PgStore extends BasePgStore { ad.proposals_accepted_count, ad.proposals_rejected_count, ad.proposals_missed_count, - COALESCE(ad.average_response_time_ms, 0) AS average_response_time_ms + COALESCE(ad.average_response_time_ms, 0) AS average_response_time_ms, + COALESCE(lrd.last_block_response_time, NULL) AS last_block_response_time FROM signer_data sd LEFT JOIN aggregated_data ad ON sd.signer_key = ad.signer_key LEFT JOIN signer_rank sr ON sd.signer_key = sr.signer_key + LEFT JOIN latest_response_data lrd + ON sd.signer_key = lrd.signer_key -- Join the latest response time data ORDER BY sd.signer_stacked_amount DESC, sd.signer_key ASC `; return dbRewardSetSigners; @@ -401,6 +413,7 @@ export class PgStore extends BasePgStore { proposals_rejected_count: number; proposals_missed_count: number; average_response_time_ms: number; + last_block_response_time: Date | null; }[] >` WITH signer_data AS ( @@ -434,6 +447,12 @@ export class PgStore extends BasePgStore { JOIN proposal_data pd ON br.signer_sighash = pd.block_hash WHERE br.signer_key = ${normalizeHexString(signerId)} -- Filter for the specific signer ), + latest_response_data AS ( + -- Find the latest response time for the specific signer + SELECT + MAX(received_at) AS last_block_response_time + FROM response_data + ), signer_proposal_data AS ( -- Cross join the specific signer with proposals and left join filtered responses SELECT @@ -478,12 +497,15 @@ export class PgStore extends BasePgStore { ad.proposals_accepted_count, ad.proposals_rejected_count, ad.proposals_missed_count, - COALESCE(ad.average_response_time_ms, 0) AS average_response_time_ms + COALESCE(ad.average_response_time_ms, 0) AS average_response_time_ms, + COALESCE(lrd.last_block_response_time, NULL) AS last_block_response_time FROM signer_data sd LEFT JOIN aggregated_data ad ON sd.signer_key = ad.signer_key LEFT JOIN signer_rank sr ON sd.signer_key = sr.signer_key + LEFT JOIN latest_response_data lrd + ON true `; return dbRewardSetSigner[0]; } diff --git a/tests/db/endpoints.test.ts b/tests/db/endpoints.test.ts index 945ebb0..04a0ae8 100644 --- a/tests/db/endpoints.test.ts +++ b/tests/db/endpoints.test.ts @@ -19,7 +19,7 @@ import { import { PoxInfo, RpcStackerSetResponse } from '../../src/stacks-core-rpc/stacks-core-rpc-client'; import { rpcStackerSetToDbRewardSetSigners } from '../../src/stacks-core-rpc/stacker-set-updater'; -describe('Postgres ingestion tests', () => { +describe('Endpoint tests', () => { let db: PgStore; let apiServer: FastifyInstance; @@ -87,10 +87,6 @@ describe('Postgres ingestion tests', () => { .expect(200); const body: BlocksResponse = responseTest.body; - const firstBlockTime = new Date(body.results[0].block_time * 1000).toISOString(); - const lastBlockTime = new Date((body.results.at(-1)?.block_time ?? 0) * 1000).toISOString(); - console.log(`First block time: ${firstBlockTime}, Last block time: ${lastBlockTime}`); - // block 112274 has all signer states (missing, rejected, accepted, accepted_excluded) const testBlock = body.results.find(r => r.block_height === 112274); assert.ok(testBlock); @@ -143,8 +139,27 @@ describe('Postgres ingestion tests', () => { proposals_rejected_count: 12, proposals_missed_count: 3, average_response_time_ms: 26273.979, + last_seen: '2024-11-02T13:33:21.831Z', }; expect(testSigner).toEqual(expectedSignerData); + + // this signer has missed all block_proposal (no block_response has been seen) + const miaSignerKey = '0x0399649284ed10a00405f032f8567b5e5463838aaa00af8d6bc9da71dda4e19c9c'; + const miaSigner = body.results.find(r => r.signer_key === miaSignerKey); + const expectedMiaSignerData: CycleSigner = { + signer_key: '0x0399649284ed10a00405f032f8567b5e5463838aaa00af8d6bc9da71dda4e19c9c', + weight: 1, + weight_percentage: 2, + stacked_amount: '7700000000000', + stacked_amount_percent: 2.283, + stacked_amount_rank: 5, + proposals_accepted_count: 0, + proposals_rejected_count: 0, + proposals_missed_count: 99, + average_response_time_ms: 0, + last_seen: null, + }; + expect(miaSigner).toEqual(expectedMiaSignerData); }); test('get signers for cycle with time range', async () => { @@ -181,6 +196,7 @@ describe('Postgres ingestion tests', () => { proposals_rejected_count: 0, proposals_missed_count: 1, average_response_time_ms: 28515, + last_seen: '2024-11-02T13:33:21.831Z', }; expect(testSigner1).toEqual(expectedSignerData1); @@ -204,7 +220,7 @@ describe('Postgres ingestion tests', () => { const signersBody3: CycleSignersResponse = signersResp3.body; const testSigner3 = signersBody3.results.find(r => r.signer_key === testSignerKey1); // should return data for the oldest block - expect(testSigner3).toEqual({ + const expected3: CycleSigner = { signer_key: '0x02e8620935d58ebffa23c260f6917cbd0915ea17d7a46df17e131540237d335504', weight: 38, weight_percentage: 76, @@ -215,7 +231,9 @@ describe('Postgres ingestion tests', () => { proposals_rejected_count: 0, proposals_missed_count: 0, average_response_time_ms: 29020, - }); + last_seen: '2024-11-02T13:30:43.731Z', + }; + expect(testSigner3).toEqual(expected3); }); test('get signer for cycle', async () => { @@ -236,7 +254,29 @@ describe('Postgres ingestion tests', () => { proposals_rejected_count: 12, proposals_missed_count: 3, average_response_time_ms: 26273.979, + last_seen: '2024-11-02T13:33:21.831Z', }; expect(body).toEqual(expectedSignerData); + + // this signer has missed all block_proposal (no block_response has been seen) + const miaSignerKey = '0x0399649284ed10a00405f032f8567b5e5463838aaa00af8d6bc9da71dda4e19c9c'; + const responseTest2 = await supertest(apiServer.server) + .get(`/signer-metrics/v1/cycles/72/signers/${miaSignerKey}`) + .expect(200); + const miaSigner: CycleSignerResponse = responseTest2.body; + const expectedMiaSignerData: CycleSigner = { + signer_key: '0x0399649284ed10a00405f032f8567b5e5463838aaa00af8d6bc9da71dda4e19c9c', + weight: 1, + weight_percentage: 2, + stacked_amount: '7700000000000', + stacked_amount_percent: 2.283, + stacked_amount_rank: 5, + proposals_accepted_count: 0, + proposals_rejected_count: 0, + proposals_missed_count: 99, + average_response_time_ms: 0, + last_seen: null, + }; + expect(miaSigner).toEqual(expectedMiaSignerData); }); }); From abb9cef249286a4ff5cd96ab7f776a8b103a69dc Mon Sep 17 00:00:00 2001 From: Matthew Little Date: Tue, 5 Nov 2024 15:16:49 +0100 Subject: [PATCH 2/4] chore: pretty print json responses --- src/api/init.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/api/init.ts b/src/api/init.ts index 3905b59..1ec8bbb 100644 --- a/src/api/init.ts +++ b/src/api/init.ts @@ -32,6 +32,15 @@ export async function buildApiServer(args: { db: PgStore }) { await fastify.register(FastifyCors); await fastify.register(Api, { prefix: '/signer-metrics' }); + fastify.addHook('onSend', async (_req, reply, payload) => { + if ((reply.getHeader('Content-Type') as string).startsWith('application/json')) { + // Pretty-print with indentation + return JSON.stringify(JSON.parse(payload as string), null, 2); + } else { + return payload; + } + }); + return fastify; } From 5323999abdc5dfea46e7f437523f6bc6823a41ab Mon Sep 17 00:00:00 2001 From: Matthew Little Date: Tue, 5 Nov 2024 15:21:03 +0100 Subject: [PATCH 3/4] chore: schema desc fix --- src/api/schemas.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/api/schemas.ts b/src/api/schemas.ts index c38f18a..71024e4 100644 --- a/src/api/schemas.ts +++ b/src/api/schemas.ts @@ -159,12 +159,9 @@ export const CycleSignerSchema = Type.Object({ description: 'Time duration (in milliseconds) taken to submit responses to block proposals (tracked best effort)', }), - last_seen: Type.Union([ - Type.String({ - description: 'ISO timestamp of the last time a message from this signer was seen', - }), - Type.Null(), - ]), + last_seen: Type.Union([Type.String(), Type.Null()], { + description: 'ISO timestamp of the last time a message from this signer was seen', + }), // TODO: implement these nice-to-have fields /* mined_blocks_accepted_included_count: Type.Integer({ From 190aea74bc77a86ef53e1afa1459ad8af78780a3 Mon Sep 17 00:00:00 2001 From: Matthew Little Date: Tue, 5 Nov 2024 15:48:37 +0100 Subject: [PATCH 4/4] feat: include signer version --- src/api/routes/cycle.ts | 2 ++ src/api/schemas.ts | 3 +++ src/pg/pg-store.ts | 33 ++++++++++++++++++++++----------- tests/db/endpoints.test.ts | 10 ++++++++++ 4 files changed, 37 insertions(+), 11 deletions(-) diff --git a/src/api/routes/cycle.ts b/src/api/routes/cycle.ts index 7db8490..24efafb 100644 --- a/src/api/routes/cycle.ts +++ b/src/api/routes/cycle.ts @@ -85,6 +85,7 @@ export const CycleRoutes: FastifyPluginCallback< proposals_missed_count: result.proposals_missed_count, average_response_time_ms: result.average_response_time_ms, last_seen: result.last_block_response_time?.toISOString() ?? null, + version: result.last_metadata_server_version ?? null, }; return cycleSinger; }); @@ -144,6 +145,7 @@ export const CycleRoutes: FastifyPluginCallback< proposals_missed_count: signer.proposals_missed_count, average_response_time_ms: signer.average_response_time_ms, last_seen: signer.last_block_response_time?.toISOString() ?? null, + version: signer.last_metadata_server_version ?? null, }; return cycleSigner; }); diff --git a/src/api/schemas.ts b/src/api/schemas.ts index 71024e4..d9b8810 100644 --- a/src/api/schemas.ts +++ b/src/api/schemas.ts @@ -162,6 +162,9 @@ export const CycleSignerSchema = Type.Object({ last_seen: Type.Union([Type.String(), Type.Null()], { description: 'ISO timestamp of the last time a message from this signer was seen', }), + version: Type.Union([Type.String(), Type.Null()], { + description: 'The last seen signer binary version reported by this signer', + }), // TODO: implement these nice-to-have fields /* mined_blocks_accepted_included_count: Type.Integer({ diff --git a/src/pg/pg-store.ts b/src/pg/pg-store.ts index 823ddd5..a6fe1f4 100644 --- a/src/pg/pg-store.ts +++ b/src/pg/pg-store.ts @@ -300,6 +300,7 @@ export class PgStore extends BasePgStore { proposals_missed_count: number; average_response_time_ms: number; last_block_response_time: Date | null; + last_metadata_server_version: string | null; }[] >` WITH signer_data AS ( @@ -330,17 +331,19 @@ export class PgStore extends BasePgStore { br.signer_sighash, br.accepted, br.received_at, + br.metadata_server_version, br.id FROM block_responses br JOIN proposal_data pd ON br.signer_sighash = pd.block_hash -- Only responses linked to selected proposals ), latest_response_data AS ( - -- Find the latest response time for each signer - SELECT + -- Find the latest response time and corresponding metadata_server_version for each signer + SELECT DISTINCT ON (signer_key) signer_key, - MAX(received_at) AS last_block_response_time + received_at AS last_block_response_time, + metadata_server_version FROM response_data - GROUP BY signer_key + ORDER BY signer_key, received_at DESC ), signer_proposal_data AS ( -- Cross join signers with proposals and left join filtered responses @@ -387,14 +390,15 @@ export class PgStore extends BasePgStore { ad.proposals_rejected_count, ad.proposals_missed_count, COALESCE(ad.average_response_time_ms, 0) AS average_response_time_ms, - COALESCE(lrd.last_block_response_time, NULL) AS last_block_response_time + COALESCE(lrd.last_block_response_time, NULL) AS last_block_response_time, + COALESCE(lrd.metadata_server_version, NULL) AS last_metadata_server_version FROM signer_data sd LEFT JOIN aggregated_data ad ON sd.signer_key = ad.signer_key LEFT JOIN signer_rank sr ON sd.signer_key = sr.signer_key LEFT JOIN latest_response_data lrd - ON sd.signer_key = lrd.signer_key -- Join the latest response time data + ON sd.signer_key = lrd.signer_key -- Join the latest response time and metadata server version data ORDER BY sd.signer_stacked_amount DESC, sd.signer_key ASC `; return dbRewardSetSigners; @@ -414,6 +418,7 @@ export class PgStore extends BasePgStore { proposals_missed_count: number; average_response_time_ms: number; last_block_response_time: Date | null; + last_metadata_server_version: string | null; }[] >` WITH signer_data AS ( @@ -442,16 +447,20 @@ export class PgStore extends BasePgStore { br.signer_sighash, br.accepted, br.received_at, + br.metadata_server_version, br.id FROM block_responses br JOIN proposal_data pd ON br.signer_sighash = pd.block_hash WHERE br.signer_key = ${normalizeHexString(signerId)} -- Filter for the specific signer ), latest_response_data AS ( - -- Find the latest response time for the specific signer - SELECT - MAX(received_at) AS last_block_response_time + -- Find the latest response time and corresponding metadata_server_version for the specific signer + SELECT DISTINCT ON (signer_key) + signer_key, + received_at AS last_block_response_time, + metadata_server_version FROM response_data + ORDER BY signer_key, received_at DESC ), signer_proposal_data AS ( -- Cross join the specific signer with proposals and left join filtered responses @@ -461,6 +470,7 @@ export class PgStore extends BasePgStore { pd.proposal_received_at, rd.accepted, rd.received_at AS response_received_at, + rd.metadata_server_version, EXTRACT(MILLISECOND FROM (rd.received_at - pd.proposal_received_at)) AS response_time_ms FROM signer_data sd CROSS JOIN proposal_data pd @@ -498,14 +508,15 @@ export class PgStore extends BasePgStore { ad.proposals_rejected_count, ad.proposals_missed_count, COALESCE(ad.average_response_time_ms, 0) AS average_response_time_ms, - COALESCE(lrd.last_block_response_time, NULL) AS last_block_response_time + COALESCE(lrd.last_block_response_time, NULL) AS last_block_response_time, + COALESCE(lrd.metadata_server_version, NULL) AS last_metadata_server_version FROM signer_data sd LEFT JOIN aggregated_data ad ON sd.signer_key = ad.signer_key LEFT JOIN signer_rank sr ON sd.signer_key = sr.signer_key LEFT JOIN latest_response_data lrd - ON true + ON sd.signer_key = lrd.signer_key `; return dbRewardSetSigner[0]; } diff --git a/tests/db/endpoints.test.ts b/tests/db/endpoints.test.ts index 04a0ae8..91e37ca 100644 --- a/tests/db/endpoints.test.ts +++ b/tests/db/endpoints.test.ts @@ -140,6 +140,8 @@ describe('Endpoint tests', () => { proposals_missed_count: 3, average_response_time_ms: 26273.979, last_seen: '2024-11-02T13:33:21.831Z', + version: + 'stacks-signer signer-3.0.0.0.0.1 (release/signer-3.0.0.0.0.1:b26f406, release build, linux [x86_64])', }; expect(testSigner).toEqual(expectedSignerData); @@ -158,6 +160,7 @@ describe('Endpoint tests', () => { proposals_missed_count: 99, average_response_time_ms: 0, last_seen: null, + version: null, }; expect(miaSigner).toEqual(expectedMiaSignerData); }); @@ -197,6 +200,8 @@ describe('Endpoint tests', () => { proposals_missed_count: 1, average_response_time_ms: 28515, last_seen: '2024-11-02T13:33:21.831Z', + version: + 'stacks-signer signer-3.0.0.0.0.1 (release/signer-3.0.0.0.0.1:b26f406, release build, linux [x86_64])', }; expect(testSigner1).toEqual(expectedSignerData1); @@ -232,6 +237,8 @@ describe('Endpoint tests', () => { proposals_missed_count: 0, average_response_time_ms: 29020, last_seen: '2024-11-02T13:30:43.731Z', + version: + 'stacks-signer signer-3.0.0.0.0.1 (release/signer-3.0.0.0.0.1:b26f406, release build, linux [x86_64])', }; expect(testSigner3).toEqual(expected3); }); @@ -255,6 +262,8 @@ describe('Endpoint tests', () => { proposals_missed_count: 3, average_response_time_ms: 26273.979, last_seen: '2024-11-02T13:33:21.831Z', + version: + 'stacks-signer signer-3.0.0.0.0.1 (release/signer-3.0.0.0.0.1:b26f406, release build, linux [x86_64])', }; expect(body).toEqual(expectedSignerData); @@ -276,6 +285,7 @@ describe('Endpoint tests', () => { proposals_missed_count: 99, average_response_time_ms: 0, last_seen: null, + version: null, }; expect(miaSigner).toEqual(expectedMiaSignerData); });