Monitoring
Track idempotency operations and debug issues.
Available Metrics
When using MetricsPlugin alongside IdempotencyPlugin, the following metrics are emitted:
| Metric | Type | Labels | Description |
|---|---|---|---|
redisx_idempotency_requests_total | Counter | status=new | First-time requests |
redisx_idempotency_requests_total | Counter | status=replay | Duplicate/cached responses |
redisx_idempotency_requests_total | Counter | status=mismatch | Fingerprint mismatches |
redisx_idempotency_duration_seconds | Histogram | — | Processing duration |
Prometheus Integration
Metrics are automatically emitted when MetricsPlugin is registered alongside IdempotencyPlugin:
typescript
import { Module } from '@nestjs/common';
import { RedisModule } from '@nestjs-redisx/core';
import { MetricsPlugin } from '@nestjs-redisx/metrics';
import { IdempotencyPlugin } from '@nestjs-redisx/idempotency';
@Module({
imports: [
RedisModule.forRoot({
clients: { host: 'localhost', port: 6379 },
plugins: [
new MetricsPlugin(),
new IdempotencyPlugin(),
],
}),
],
})
export class AppModule {}Metrics Endpoint
bash
curl http://localhost:3000/metrics
# Output:
# redisx_idempotency_requests_total{status="new"} 150
# redisx_idempotency_requests_total{status="replay"} 45
# redisx_idempotency_requests_total{status="mismatch"} 2
# redisx_idempotency_duration_seconds_bucket{le="0.1"} 120
# redisx_idempotency_duration_seconds_bucket{le="0.5"} 145Grafana Dashboard
Query Examples
New vs Replay Requests:
yaml
sum(rate(redisx_idempotency_requests_total{status="new"}[5m]))
sum(rate(redisx_idempotency_requests_total{status="replay"}[5m]))Replay Request Rate:
yaml
(
sum(rate(redisx_idempotency_requests_total{status="replay"}[5m]))
/
sum(rate(redisx_idempotency_requests_total[5m]))
) * 100P95 Processing Duration:
yaml
histogram_quantile(0.95,
rate(redisx_idempotency_duration_seconds_bucket[5m])
)Fingerprint Mismatch Rate:
yaml
rate(redisx_idempotency_requests_total{status="mismatch"}[5m])Alert Rules
High Fingerprint Mismatch Rate:
yaml
- alert: HighFingerprintMismatchRate
expr: |
rate(redisx_idempotency_requests_total{status="mismatch"}[5m]) > 5
for: 5m
labels:
severity: warning
annotations:
summary: "High idempotency fingerprint mismatch rate"
description: "{{ $value }} mismatches per second"Low New Request Success Rate:
yaml
- alert: IdempotencyHighReplayRate
expr: |
(
sum(rate(redisx_idempotency_requests_total{status="replay"}[5m]))
/
sum(rate(redisx_idempotency_requests_total[5m]))
) > 0.5
for: 5m
labels:
severity: warning
annotations:
summary: "Over 50% of idempotent requests are replays"Logging
Enable Debug Logging
Use NestJS logger to see idempotency debug output. Set the log level to debug in your application:
typescript
const app = await NestFactory.create(AppModule, {
logger: ['debug', 'log', 'warn', 'error'],
});Log Output
[IdempotencyInterceptor] New request: key=payment-123, fingerprint=abc123
[IdempotencyInterceptor] Duplicate request: key=payment-123
[IdempotencyInterceptor] Fingerprint mismatch: key=payment-123
[IdempotencyInterceptor] Concurrent request: key=payment-123, waiting=true
[IdempotencyInterceptor] Timeout: key=payment-123Structured Logging
typescript
import { Injectable, Logger } from '@nestjs/common';
@Injectable()
export class IdempotencyLogger {
private readonly logger = new Logger('Idempotency');
onNewRequest(key: string, fingerprint: string): void {
this.logger.log({
event: 'new_request',
key,
fingerprint,
timestamp: new Date().toISOString(),
});
}
onDuplicate(key: string, cachedAt: Date): void {
this.logger.log({
event: 'duplicate_request',
key,
cachedAt: cachedAt.toISOString(),
age: Date.now() - cachedAt.getTime(),
});
}
onMismatch(key: string, expected: string, received: string): void {
this.logger.warn({
event: 'fingerprint_mismatch',
key,
expected,
received,
});
}
}Redis Inspection
List All Keys
bash
redis-cli --scan --pattern 'idempotency:*'
# Output:
# idempotency:payment-123
# idempotency:order-456
# idempotency:transfer-789Inspect Specific Key
Idempotency records are stored as Redis hashes:
bash
redis-cli HGETALL idempotency:payment-123
# Output:
# 1) "fingerprint"
# 2) "abc123..."
# 3) "status"
# 4) "completed"
# 5) "statusCode"
# 6) "201"
# 7) "response"
# 8) "{\"id\":456}"
# 9) "startedAt"
# 10) "1706123456000"
# 11) "completedAt"
# 12) "1706123457000"Check TTL
bash
redis-cli TTL idempotency:payment-123
# Output:
# 86342 (seconds remaining)Count Records by Status
bash
redis-cli --eval count-by-status.lua , idempotency:
# Lua script:
local keys = redis.call('KEYS', ARGV[1] .. '*')
local counts = {processing=0, completed=0, failed=0}
for _, key in ipairs(keys) do
local status = redis.call('HGET', key, 'status')
if status then
counts[status] = (counts[status] or 0) + 1
end
end
return cjson.encode(counts)Custom Monitoring
Event Emitter
typescript
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { OnEvent } from '@nestjs/event-emitter';
import { AlertService } from './types';
@Injectable()
export class IdempotencyMonitor {
constructor(private eventEmitter: EventEmitter2) {}
onNewRequest(key: string): void {
this.eventEmitter.emit('idempotency.new', { key });
}
onDuplicate(key: string, age: number): void {
this.eventEmitter.emit('idempotency.duplicate', { key, age });
}
onMismatch(key: string): void {
this.eventEmitter.emit('idempotency.mismatch', { key });
}
}
// Listen to events
@Injectable()
export class IdempotencyListener {
constructor(private readonly alertService: AlertService) {}
@OnEvent('idempotency.duplicate')
handleDuplicate(payload: { key: string; age: number }): void {
if (payload.age < 1000) {
console.warn(`Very fast duplicate for ${payload.key}`);
}
}
@OnEvent('idempotency.mismatch')
handleMismatch(payload: { key: string }): void {
// Alert on fingerprint mismatch
this.alertService.send(`Fingerprint mismatch: ${payload.key}`);
}
}Debugging
Debug Mode
Enable NestJS debug logging to see internal idempotency operations:
typescript
const app = await NestFactory.create(AppModule, {
logger: ['debug', 'log', 'warn', 'error'],
});Example output:
[IdempotencyService] Check: key=payment-123
[IdempotencyService] Fingerprint generated: abc123
[IdempotencyService] Redis lookup: found=true, status=processing
[IdempotencyService] Waiting for completion: key=payment-123
[IdempotencyService] Response cached: key=payment-123, ttl=86400sTrace Requests
typescript
import { Span } from '@opentelemetry/api';
@Injectable()
export class IdempotencyTracer {
async checkIdempotency(key: string, span: Span): Promise<void> {
span.setAttribute('idempotency.key', key);
const record = await this.get(key);
span.setAttribute('idempotency.status', record?.status || 'new');
span.setAttribute('idempotency.cached', !!record);
if (record) {
const age = Date.now() - record.completedAt;
span.setAttribute('idempotency.cache_age_ms', age);
}
}
}Health Checks
typescript
import { Injectable, Inject } from '@nestjs/common';
import { HealthIndicator, HealthIndicatorResult } from '@nestjs/terminus';
import {
IDEMPOTENCY_SERVICE,
IIdempotencyService,
} from '@nestjs-redisx/idempotency';
@Injectable()
export class IdempotencyHealthIndicator extends HealthIndicator {
constructor(
@Inject(IDEMPOTENCY_SERVICE)
private idempotency: IIdempotencyService,
) {
super();
}
async isHealthy(): Promise<HealthIndicatorResult> {
try {
// Test write
const testKey = `health-check-${Date.now()}`;
await this.idempotency.checkAndLock(testKey, 'health-check');
await this.idempotency.complete(testKey, {
statusCode: 200,
body: {},
});
// Test read
const record = await this.idempotency.get(testKey);
// Cleanup
await this.idempotency.delete(testKey);
return this.getStatus('idempotency', true, {
message: 'Idempotency service healthy',
});
} catch (error) {
return this.getStatus('idempotency', false, {
message: error.message,
});
}
}
}Dashboard Example
Admin Endpoint
typescript
import { Controller, Get, Inject, Param, NotFoundException } from '@nestjs/common';
import {
IDEMPOTENCY_SERVICE,
IIdempotencyService,
} from '@nestjs-redisx/idempotency';
import { IdempotencyStats } from './types';
@Controller('admin/idempotency')
export class IdempotencyAdminController {
constructor(
@Inject(IDEMPOTENCY_SERVICE)
private readonly idempotency: IIdempotencyService,
) {}
@Get('stats')
async getStats(): Promise<IdempotencyStats> {
return {
total: await this.countTotal(),
byStatus: await this.countByStatus(),
recentDuplicates: await this.getRecentDuplicates(),
topKeys: await this.getTopKeys(),
};
}
@Get('keys/:key')
async getKeyDetails(@Param('key') key: string) {
const record = await this.idempotency.get(key);
if (!record) {
throw new NotFoundException();
}
return {
key: record.key,
status: record.status,
fingerprint: record.fingerprint,
createdAt: record.startedAt,
completedAt: record.completedAt,
};
}
private async countTotal(): Promise<number> {
return 0;
}
private async countByStatus(): Promise<Record<string, number>> {
return {};
}
private async getRecentDuplicates(): Promise<unknown[]> {
return [];
}
private async getTopKeys(): Promise<unknown[]> {
return [];
}
}Next Steps
- Testing — Test idempotent endpoints
- Troubleshooting — Debug issues