Skip to content
Back to skills

Resilience Observability Patterns

ASecurity

Resilience (circuit breaker, retry) and observability (OpenTelemetry tracing, metrics, structured logging) patterns for NestJS production APIs

  • 3 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added September 5, 2026
ai-agentsbashsqlnodeapidatabase

Works with

  • cli
  • api

Security analysis

A100/100

Scanned October 1, 2026

npx -y skills add me-cedric/agent-compass --skill resilience-observability-patterns --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Resilience Observability Patterns?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Resilience Observability Patterns
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/me-cedric-resilience-observability-patterns/badge)](https://www.skillsdirectory.com/skills/me-cedric-resilience-observability-patterns)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: resilience-observability-patterns
description: Resilience (circuit breaker, retry) and observability (OpenTelemetry tracing, metrics, structured logging) patterns for NestJS production APIs
version: 1.0.0
filePattern: "**/resilience/**,**/tracing/**,**/metrics*,**/otel*,**/*logger*"
bashPattern: "otel|opentelemetry|circuit|resilience|metrics"
risk_level: medium
writes_files: false
requires_tools: []
---

# Resilience & Observability Patterns

## Circuit Breaker + Retry (Cockatiel)

### ResilienceService (Global)

```ts
@Global()
@Module({ providers: [ResilienceService], exports: [ResilienceService] })
export class ResilienceModule {}

@Injectable()
export class ResilienceService {
  createPolicy(name: string, options: ResilienceOptions): ResiliencePolicy {
    const breaker = new ConsecutiveBreaker(options.circuitBreaker.failureThreshold);
    const circuitBreaker = new CircuitBreakerPolicy({
      breaker,
      halfOpenAfter: options.circuitBreaker.openDuration,
    });

    const retry = new RetryPolicy({
      maxAttempts: options.retry.maxAttempts,
      backoff: new ExponentialBackoff({ initialDelay: options.retry.delay }),
    });

    // Wrap retry around circuit breaker
    const policy = Policy.wrap(retry, circuitBreaker);

    // Record state changes for metrics
    circuitBreaker.onStateChange((state) => {
      this.metrics.recordCircuitBreakerStateChange(name, state);
    });

    return { execute: (fn) => policy.execute(fn) };
  }
}
```

### Usage Pattern (CRITICAL: Create Once in OnModuleInit)

```ts
@Injectable()
export class ExternalApiService implements OnModuleInit {
  private policy!: ResiliencePolicy;

  constructor(private readonly resilience: ResilienceService) {}

  onModuleInit(): void {
    // Create ONCE — not per-call
    this.policy = this.resilience.createPolicy('external-api', {
      circuitBreaker: {
        failureThreshold: 3,      // Open after 3 consecutive failures
        openDuration: 60_000,     // Stay open for 60s
        halfOpenMaxAttempts: 1,   // Allow 1 test request in half-open
      },
      retry: {
        maxAttempts: 3,           // Retry up to 3 times
        delay: 2_000,            // 2s initial backoff (exponential)
      },
    });
  }

  async fetchData(): Promise<Data> {
    return this.policy.execute(() => this.httpClient.get('/data'));
  }
}
```

**Why OnModuleInit:** Creating a new policy per call resets the circuit breaker state, defeating its purpose. The breaker needs to accumulate failure counts across calls.

### Decorator Alternative (@UseResilience)

```ts
@UseResilience({
  circuitBreaker: { failureThreshold: 5, openDuration: 30_000 },
  retry: { maxAttempts: 2, delay: 1_000 },
})
async fetchExternalData(): Promise<Data> {
  return this.httpClient.get('/external');
}
```

The decorator caches policies by `serviceName:methodName`, handles `BrokenCircuitError` -> 503, Axios errors -> upstream status, unknown -> 502.

### Error Handling

```ts
try {
  return await this.policy.execute(() => this.api.call());
} catch (error) {
  if (error instanceof BrokenCircuitError) {
    throw new ServiceUnavailableException('Service temporarily unavailable');
  }
  throw error;
}
```

---

## OpenTelemetry Setup

### SDK Initialization (MUST be first import)

```ts
// tracing.ts — loaded before NestJS bootstrap
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http';
import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';

const sdk = new NodeSDK({
  serviceName: 'my-api',
  traceExporter: new OTLPTraceExporter({ url: `${OTEL_ENDPOINT}/v1/traces` }),
  metricReader: new PeriodicExportingMetricReader({
    exporter: new OTLPMetricExporter({ url: `${OTEL_ENDPOINT}/v1/metrics` }),
    exportIntervalMillis: 30_000,
  }),
  instrumentations: [
    getNodeAutoInstrumentations({
      '@opentelemetry/instrumentation-fs': { enabled: false },
      '@opentelemetry/instrumentation-http': { enabled: true },
      '@opentelemetry/instrumentation-pg': {
        enabled: true,
        // Keep this false. It adds the bound parameter VALUES to the span
        // (`db.postgresql.values`), so any query over personal data copies
        // that data into telemetry. The statement stays available either
        // way: the instrumentation always sets `db.query.text`.
        enhancedDatabaseReporting: false,
      },
    }),
  ],
  sampler: process.env.NODE_ENV === 'production'
    ? new TraceIdRatioBasedSampler(0.5) // 50% in prod
    : new AlwaysOnSampler(),            // 100% in dev
});

sdk.start();
```

**In main.ts:**
```ts
// FIRST LINE — before any NestJS imports
import './tracing/tracing';
import { config } from 'dotenv';
config();
// ... then NestJS bootstrap
```

### OtelLogger (Trace-Correlated Logging)

```ts
@Injectable({ scope: Scope.TRANSIENT })
export class OtelLogger {
  private context = 'App';
  private otelLogger: OtelLoggerApi;

  constructor() {
    this.otelLogger = logs.getLogger('app-logger');
  }

  setContext(name: string) { this.context = name; }

  log(message: string, ...args: unknown[]) {
    this.emit(SeverityNumber.INFO, message);
    // Also log to console for local dev
    console.log(`[${this.context}] ${message}`, ...args);
  }

  error(message: string, trace?: string) {
    this.emit(SeverityNumber.ERROR, message);
    // Add error event to active span
    const span = trace_api.getActiveSpan();
    span?.addEvent('error', { message, stack: trace });
  }

  private emit(severity: SeverityNumber, message: string) {
    const span = trace_api.getActiveSpan();
    this.otelLogger.emit({
      severityNumber: severity,
      body: message,
      attributes: {
        context: this.context,
        ...(span ? { traceId: span.spanContext().traceId } : {}),
      },
    });
  }
}
```

**When to use which logger:**
- `OtelLogger` — processors, services calling external APIs, anything needing trace correlation
- `Logger` from `@nestjs/common` — simple services, guards, pipes (no trace context needed)

### MetricsService

```ts
@Injectable()
export class MetricsService {
  private httpRequestCounter: Counter;
  private httpDurationHistogram: Histogram;
  private paymentCounter: Counter;
  private jobDurationHistogram: Histogram;

  constructor() {
    const meter = metrics.getMeter('app-metrics');

    this.httpRequestCounter = meter.createCounter('http.requests', {
      description: 'Total HTTP requests',
    });
    this.httpDurationHistogram = meter.createHistogram('http.request.duration', {
      description: 'HTTP request duration in ms',
      unit: 'ms',
    });
    this.paymentCounter = meter.createCounter('payments.processed');
    this.jobDurationHistogram = meter.createHistogram('bullmq.job.duration', {
      unit: 'ms',
    });
  }

  recordHttpRequest(method: string, path: string, status: number, durationMs: number) {
    this.httpRequestCounter.add(1, { method, path, status });
    this.httpDurationHistogram.record(durationMs, { method, path, status });
  }

  recordJobCompleted(queueName: string, jobName: string, durationMs: number, success: boolean) {
    this.jobDurationHistogram.record(durationMs, { queue: queueName, job: jobName, success });
  }
}
```

### Custom Spans

```ts
import { trace } from '@opentelemetry/api';

const tracer = trace.getTracer('feature-tracer');

async function doWork() {
  return tracer.startActiveSpan('feature.do-work', async (span) => {
    try {
      span.setAttribute('feature.id', featureId);
      const result = await heavyOperation();
      span.setStatus({ code: SpanStatusCode.OK });
      return result;
    } catch (error) {
      span.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
      span.recordException(error);
      throw error;
    } finally {
      span.end();
    }
  });
}
```

### withJobSpan (Queue Trace Propagation)

```ts
// Captures OTel context at enqueue time
async addJob(queue, name, data, opts) {
  const context = propagation.inject({}, defaultTextMapSetter);
  await queue.add(name, { ...data, __otelContext: context }, opts);
}

// Restores context in processor
async function withJobSpan(job, spanName, metrics, fn) {
  const parentContext = propagation.extract(context.active(), job.data.__otelContext);
  return context.with(parentContext, () =>
    tracer.startActiveSpan(spanName, { attributes: { 'job.id': job.id } }, async (span) => {
      try {
        const result = await fn();
        metrics.recordJobCompleted(job.queueName, job.name, duration, true);
        return result;
      } catch (error) {
        metrics.recordJobCompleted(job.queueName, job.name, duration, false);
        span.recordException(error);
        throw error;
      } finally {
        span.end();
      }
    }),
  );
}
```

This creates a continuous trace: `HTTP request` -> `queue.add` -> `processor.process` -> `service.action`.

---

## Naming Conventions

| Metric/Span | Pattern | Example |
|---|---|---|
| HTTP counter | `http.requests` | `http.requests{method=GET, path=/api/v1/features}` |
| HTTP histogram | `http.request.duration` | Duration in ms |
| Business counter | `{domain}.{action}` | `payments.processed`, `users.registered` |
| Job histogram | `bullmq.job.duration` | `{queue=feature, job=process}` |
| Span name | `{module}.{operation}` | `resources.update-availability` |
| Circuit breaker | `resilience.circuit_breaker.state` | `{name=external-api, state=OPEN}` |

## TracingModule Registration

```ts
@Global()
@Module({
  providers: [OtelLogger, MetricsService],
  exports: [OtelLogger, MetricsService],
})
export class TracingModule {}
```

Registered in AppModule imports — available globally without per-module imports.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.