Skip to content

Commit 18e68ad

Browse files
authored
Merge pull request #440 from Meshmulla/feature/345-346-transactions-metrics-and-env-validation
feat: add metrics instrumentation and env validation at startup
2 parents 28cad96 + 8d4c973 commit 18e68ad

7 files changed

Lines changed: 401 additions & 15 deletions
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { ConfigService } from '@nestjs/config';
3+
import { TransactionEnvValidatorService } from './transaction-env-validator.service';
4+
5+
const makeConfigService = (values: Record<string, string | undefined>) => ({
6+
get: jest.fn((key: string) => values[key]),
7+
});
8+
9+
const ALL_VARS_PRESENT = {
10+
DATABASE_URL: 'postgresql://user:pass@localhost:5432/mux_db',
11+
STELLAR_HORIZON_URL: 'https://horizon-testnet.stellar.org',
12+
};
13+
14+
describe('TransactionEnvValidatorService', () => {
15+
async function buildService(
16+
envValues: Record<string, string | undefined>,
17+
): Promise<TransactionEnvValidatorService> {
18+
const module: TestingModule = await Test.createTestingModule({
19+
providers: [
20+
TransactionEnvValidatorService,
21+
{
22+
provide: ConfigService,
23+
useValue: makeConfigService(envValues),
24+
},
25+
],
26+
}).compile();
27+
28+
return module.get<TransactionEnvValidatorService>(
29+
TransactionEnvValidatorService,
30+
);
31+
}
32+
33+
it('should be defined', async () => {
34+
const service = await buildService(ALL_VARS_PRESENT);
35+
expect(service).toBeDefined();
36+
});
37+
38+
describe('onModuleInit', () => {
39+
it('does not throw when all required env vars are present', async () => {
40+
const service = await buildService(ALL_VARS_PRESENT);
41+
expect(() => service.onModuleInit()).not.toThrow();
42+
});
43+
44+
it('throws when DATABASE_URL is missing', async () => {
45+
const service = await buildService({
46+
DATABASE_URL: undefined,
47+
STELLAR_HORIZON_URL: 'https://horizon-testnet.stellar.org',
48+
});
49+
50+
expect(() => service.onModuleInit()).toThrow(
51+
'Transactions API is missing required environment variables: DATABASE_URL',
52+
);
53+
});
54+
55+
it('throws when STELLAR_HORIZON_URL is missing', async () => {
56+
const service = await buildService({
57+
DATABASE_URL: 'postgresql://user:pass@localhost:5432/mux_db',
58+
STELLAR_HORIZON_URL: undefined,
59+
});
60+
61+
expect(() => service.onModuleInit()).toThrow(
62+
'Transactions API is missing required environment variables: STELLAR_HORIZON_URL',
63+
);
64+
});
65+
66+
it('lists all missing vars in the error message when multiple are absent', async () => {
67+
const service = await buildService({
68+
DATABASE_URL: undefined,
69+
STELLAR_HORIZON_URL: undefined,
70+
});
71+
72+
expect(() => service.onModuleInit()).toThrow(
73+
'DATABASE_URL, STELLAR_HORIZON_URL',
74+
);
75+
});
76+
77+
it('does not throw when called multiple times with valid config', async () => {
78+
const service = await buildService(ALL_VARS_PRESENT);
79+
expect(() => {
80+
service.onModuleInit();
81+
service.onModuleInit();
82+
}).not.toThrow();
83+
});
84+
});
85+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
2+
import { ConfigService } from '@nestjs/config';
3+
4+
const REQUIRED_VARS: ReadonlyArray<string> = [
5+
'DATABASE_URL',
6+
'STELLAR_HORIZON_URL',
7+
];
8+
9+
@Injectable()
10+
export class TransactionEnvValidatorService implements OnModuleInit {
11+
private readonly logger = new Logger(TransactionEnvValidatorService.name);
12+
13+
constructor(private readonly configService: ConfigService) {}
14+
15+
onModuleInit(): void {
16+
const missing: string[] = [];
17+
18+
for (const key of REQUIRED_VARS) {
19+
const value = this.configService.get<string>(key);
20+
if (!value) {
21+
missing.push(key);
22+
}
23+
}
24+
25+
if (missing.length > 0) {
26+
const msg = `Transactions API is missing required environment variables: ${missing.join(', ')}`;
27+
this.logger.error(msg);
28+
throw new Error(msg);
29+
}
30+
31+
this.logger.log('Transactions API environment validated successfully');
32+
}
33+
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { TransactionMetricsService } from './transaction-metrics.service';
3+
4+
describe('TransactionMetricsService', () => {
5+
let service: TransactionMetricsService;
6+
7+
beforeEach(async () => {
8+
const module: TestingModule = await Test.createTestingModule({
9+
providers: [TransactionMetricsService],
10+
}).compile();
11+
12+
service = module.get<TransactionMetricsService>(TransactionMetricsService);
13+
});
14+
15+
it('should be defined', () => {
16+
expect(service).toBeDefined();
17+
});
18+
19+
describe('getSnapshot initial state', () => {
20+
it('returns all-zero counters when no events have been recorded', () => {
21+
const snap = service.getSnapshot();
22+
expect(snap.transactionsCreatedTotal).toBe(0);
23+
expect(snap.transactionsStatusUpdatedTotal).toBe(0);
24+
expect(snap.transactionsFailedTotal).toBe(0);
25+
expect(snap.idempotencyHitsTotal).toBe(0);
26+
expect(snap.cacheHitsTotal).toBe(0);
27+
expect(snap.cacheMissesTotal).toBe(0);
28+
expect(snap.transactionsCreatedByAsset).toEqual({});
29+
expect(snap.transactionsStatusUpdatedByTransition).toEqual({});
30+
});
31+
});
32+
33+
describe('incrementTransactionCreated', () => {
34+
it('increments total counter', () => {
35+
service.incrementTransactionCreated('NATIVE');
36+
expect(service.getSnapshot().transactionsCreatedTotal).toBe(1);
37+
});
38+
39+
it('tracks per-asset-type counts', () => {
40+
service.incrementTransactionCreated('NATIVE');
41+
service.incrementTransactionCreated('NATIVE');
42+
service.incrementTransactionCreated('TOKEN');
43+
44+
const snap = service.getSnapshot();
45+
expect(snap.transactionsCreatedTotal).toBe(3);
46+
expect(snap.transactionsCreatedByAsset).toEqual({
47+
NATIVE: 2,
48+
TOKEN: 1,
49+
});
50+
});
51+
52+
it('returns a copy of the asset map so the snapshot is immutable', () => {
53+
service.incrementTransactionCreated('NATIVE');
54+
const snap = service.getSnapshot();
55+
snap.transactionsCreatedByAsset['NATIVE'] = 999;
56+
expect(service.getSnapshot().transactionsCreatedByAsset['NATIVE']).toBe(
57+
1,
58+
);
59+
});
60+
});
61+
62+
describe('incrementStatusUpdated', () => {
63+
it('increments total and records the transition key', () => {
64+
service.incrementStatusUpdated('PENDING', 'SUBMITTED');
65+
66+
const snap = service.getSnapshot();
67+
expect(snap.transactionsStatusUpdatedTotal).toBe(1);
68+
expect(snap.transactionsStatusUpdatedByTransition).toEqual({
69+
PENDING_to_SUBMITTED: 1,
70+
});
71+
});
72+
73+
it('increments transactionsFailedTotal when toStatus is FAILED', () => {
74+
service.incrementStatusUpdated('PENDING', 'FAILED');
75+
76+
const snap = service.getSnapshot();
77+
expect(snap.transactionsFailedTotal).toBe(1);
78+
});
79+
80+
it('does not increment transactionsFailedTotal for non-FAILED transitions', () => {
81+
service.incrementStatusUpdated('PENDING', 'SUBMITTED');
82+
expect(service.getSnapshot().transactionsFailedTotal).toBe(0);
83+
});
84+
85+
it('accumulates multiple different transitions', () => {
86+
service.incrementStatusUpdated('PENDING', 'SUBMITTED');
87+
service.incrementStatusUpdated('SUBMITTED', 'CONFIRMED');
88+
service.incrementStatusUpdated('PENDING', 'SUBMITTED');
89+
90+
const snap = service.getSnapshot();
91+
expect(snap.transactionsStatusUpdatedTotal).toBe(3);
92+
expect(snap.transactionsStatusUpdatedByTransition).toEqual({
93+
PENDING_to_SUBMITTED: 2,
94+
SUBMITTED_to_CONFIRMED: 1,
95+
});
96+
});
97+
});
98+
99+
describe('incrementIdempotencyHit', () => {
100+
it('increments idempotencyHitsTotal', () => {
101+
service.incrementIdempotencyHit();
102+
service.incrementIdempotencyHit();
103+
expect(service.getSnapshot().idempotencyHitsTotal).toBe(2);
104+
});
105+
});
106+
107+
describe('incrementCacheHit', () => {
108+
it('increments cacheHitsTotal', () => {
109+
service.incrementCacheHit();
110+
expect(service.getSnapshot().cacheHitsTotal).toBe(1);
111+
});
112+
});
113+
114+
describe('incrementCacheMiss', () => {
115+
it('increments cacheMissesTotal', () => {
116+
service.incrementCacheMiss();
117+
service.incrementCacheMiss();
118+
expect(service.getSnapshot().cacheMissesTotal).toBe(2);
119+
});
120+
});
121+
122+
describe('getSnapshot', () => {
123+
it('returns independent copies so mutations do not affect internal state', () => {
124+
service.incrementStatusUpdated('PENDING', 'SUBMITTED');
125+
const snap = service.getSnapshot();
126+
snap.transactionsStatusUpdatedByTransition['PENDING_to_SUBMITTED'] = 999;
127+
expect(
128+
service.getSnapshot().transactionsStatusUpdatedByTransition[
129+
'PENDING_to_SUBMITTED'
130+
],
131+
).toBe(1);
132+
});
133+
});
134+
});
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { Injectable, Logger } from '@nestjs/common';
2+
3+
export interface TransactionMetricsSnapshot {
4+
transactionsCreatedTotal: number;
5+
transactionsCreatedByAsset: Record<string, number>;
6+
transactionsStatusUpdatedTotal: number;
7+
transactionsStatusUpdatedByTransition: Record<string, number>;
8+
transactionsFailedTotal: number;
9+
idempotencyHitsTotal: number;
10+
cacheHitsTotal: number;
11+
cacheMissesTotal: number;
12+
}
13+
14+
@Injectable()
15+
export class TransactionMetricsService {
16+
private readonly logger = new Logger(TransactionMetricsService.name);
17+
18+
private transactionsCreatedTotal = 0;
19+
private readonly transactionsCreatedByAsset: Record<string, number> = {};
20+
private transactionsStatusUpdatedTotal = 0;
21+
private readonly transactionsStatusUpdatedByTransition: Record<
22+
string,
23+
number
24+
> = {};
25+
private transactionsFailedTotal = 0;
26+
private idempotencyHitsTotal = 0;
27+
private cacheHitsTotal = 0;
28+
private cacheMissesTotal = 0;
29+
30+
incrementTransactionCreated(assetType: string): void {
31+
this.transactionsCreatedTotal++;
32+
this.transactionsCreatedByAsset[assetType] =
33+
(this.transactionsCreatedByAsset[assetType] ?? 0) + 1;
34+
this.logger.debug(
35+
`transaction_created asset=${assetType} total=${this.transactionsCreatedTotal}`,
36+
);
37+
}
38+
39+
incrementStatusUpdated(fromStatus: string, toStatus: string): void {
40+
this.transactionsStatusUpdatedTotal++;
41+
const key = `${fromStatus}_to_${toStatus}`;
42+
this.transactionsStatusUpdatedByTransition[key] =
43+
(this.transactionsStatusUpdatedByTransition[key] ?? 0) + 1;
44+
if (toStatus === 'FAILED') {
45+
this.transactionsFailedTotal++;
46+
}
47+
this.logger.debug(
48+
`transaction_status_updated ${key} total=${this.transactionsStatusUpdatedTotal}`,
49+
);
50+
}
51+
52+
incrementIdempotencyHit(): void {
53+
this.idempotencyHitsTotal++;
54+
this.logger.debug(
55+
`transaction_idempotency_hit total=${this.idempotencyHitsTotal}`,
56+
);
57+
}
58+
59+
incrementCacheHit(): void {
60+
this.cacheHitsTotal++;
61+
this.logger.debug(`transaction_cache_hit total=${this.cacheHitsTotal}`);
62+
}
63+
64+
incrementCacheMiss(): void {
65+
this.cacheMissesTotal++;
66+
this.logger.debug(`transaction_cache_miss total=${this.cacheMissesTotal}`);
67+
}
68+
69+
getSnapshot(): TransactionMetricsSnapshot {
70+
return {
71+
transactionsCreatedTotal: this.transactionsCreatedTotal,
72+
transactionsCreatedByAsset: { ...this.transactionsCreatedByAsset },
73+
transactionsStatusUpdatedTotal: this.transactionsStatusUpdatedTotal,
74+
transactionsStatusUpdatedByTransition: {
75+
...this.transactionsStatusUpdatedByTransition,
76+
},
77+
transactionsFailedTotal: this.transactionsFailedTotal,
78+
idempotencyHitsTotal: this.idempotencyHitsTotal,
79+
cacheHitsTotal: this.cacheHitsTotal,
80+
cacheMissesTotal: this.cacheMissesTotal,
81+
};
82+
}
83+
}

src/transactions/transactions.module.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,20 @@ import { BalanceIndexerModule } from '../balance-indexer/balance-indexer.module'
77
import { WebhookModule } from '../webhooks/webhook.module';
88
import { CacheService } from '../common/cache/cache.service';
99
import { FeatureFlagService } from '../common/feature-flags/feature-flag.service';
10+
import { TransactionMetricsService } from './transaction-metrics.service';
11+
import { TransactionEnvValidatorService } from './transaction-env-validator.service';
1012

1113
@Module({
1214
imports: [PrismaModule, BalanceIndexerModule, WebhookModule],
1315
controllers: [TransactionsController],
14-
providers: [TransactionsService, StellarTransactionBuildService, CacheService, FeatureFlagService],
16+
providers: [
17+
TransactionsService,
18+
StellarTransactionBuildService,
19+
CacheService,
20+
FeatureFlagService,
21+
TransactionMetricsService,
22+
TransactionEnvValidatorService,
23+
],
1524
exports: [TransactionsService, StellarTransactionBuildService],
1625
})
1726
export class TransactionsModule {}

0 commit comments

Comments
 (0)