Skip to content

Monitoring

Track idempotency operations and debug issues.

Available Metrics

When using MetricsPlugin alongside IdempotencyPlugin, the following metrics are emitted:

MetricTypeLabelsDescription
redisx_idempotency_requests_totalCounterstatus=newFirst-time requests
redisx_idempotency_requests_totalCounterstatus=replayDuplicate/cached responses
redisx_idempotency_requests_totalCounterstatus=mismatchFingerprint mismatches
redisx_idempotency_duration_secondsHistogramProcessing 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"} 145

Grafana 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]))
) * 100

P95 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-123

Structured 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-789

Inspect 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=86400s

Trace 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

Released under the MIT License.