Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions docs/PROVIDER_HEALTH_CHECKS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# 🩺 Notification Provider Health Checks

This document details the independent provider-level health checking system implemented in NotifyChain (Issue #709).

---

## 1. Overview & Motivation

Operators must be able to distinguish between an internal application failure and an unreachable third-party notification destination (e.g. Discord rate limits, Webhook server downtime) without exposing credentials.

---

## 2. Health Check Capabilities

* **Independent Inspection**: Each provider (Discord, Webhooks, Telegram, Stellar RPC) is evaluated in isolation with independent latency timers.
* **Credential Redaction**: Destination tokens, passwords, and webhook keys are automatically masked (`[REDACTED_TOKEN]`) in health reports and logs.
* **Non-blocking Timeout**: Health checks utilize abort controllers with customizable timeouts (default: 5,000ms).

---

## 3. Health Report Structure

```json
{
"status": "healthy",
"timestamp": "2026-08-29T12:00:00.000Z",
"providers": {
"Discord": {
"providerName": "Discord",
"status": "healthy",
"latencyMs": 42,
"lastCheckedAt": "2026-08-29T12:00:00.000Z",
"sanitizedTarget": "https://discord.com/api/webhooks/123/[REDACTED_TOKEN]"
},
"CustomWebhook": {
"providerName": "CustomWebhook",
"status": "healthy",
"latencyMs": 88,
"lastCheckedAt": "2026-08-29T12:00:00.000Z",
"sanitizedTarget": "https://api.example.com/notifications"
}
}
}
```
82 changes: 82 additions & 0 deletions listener/src/services/provider-health-monitor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import {
checkHttpProviderHealth,
getProviderHealthReport,
sanitizeProviderUrl,
} from './provider-health-monitor';

describe('Notification Provider Health Checks (Issue #709)', () => {
describe('sanitizeProviderUrl', () => {
test('strips discord secret tokens from webhook URLs', () => {
const raw = 'https://discord.com/api/webhooks/1234567890/SecretAuthTokenXYZ';
const sanitized = sanitizeProviderUrl(raw);

expect(sanitized).toBe('https://discord.com/api/webhooks/1234567890/[REDACTED_TOKEN]');
expect(sanitized).not.toContain('SecretAuthTokenXYZ');
});

test('strips basic auth username/passwords from URLs', () => {
const raw = 'https://user:mypassword@example.com/webhook';
const sanitized = sanitizeProviderUrl(raw);

expect(sanitized).not.toContain('mypassword');
expect(sanitized).toContain('[REDACTED]');
});
});

describe('checkHttpProviderHealth', () => {
test('returns disabled status when provider URL is not configured', async () => {
const health = await checkHttpProviderHealth('Webhook', undefined);
expect(health.status).toBe('disabled');
expect(health.sanitizedTarget).toBe('Not Configured');
});

test('returns healthy status when provider responds 200 OK', async () => {
const mockFetch = jest.fn().mockResolvedValue({
ok: true,
status: 200,
}) as unknown as typeof fetch;

const health = await checkHttpProviderHealth('Discord', 'https://discord.com/api/webhooks/123/token', {
fetchFn: mockFetch,
});

expect(health.status).toBe('healthy');
expect(health.sanitizedTarget).toContain('[REDACTED_TOKEN]');
expect(health.latencyMs).toBeGreaterThanOrEqual(0);
});

test('handles provider failures without leaking credentials', async () => {
const mockFetch = jest.fn().mockRejectedValue(new Error('Connection refused to secret-server.com:8443')) as unknown as typeof fetch;

const health = await checkHttpProviderHealth('Webhook', 'https://secret-server.com/hook', {
fetchFn: mockFetch,
});

expect(health.status).toBe('unhealthy');
expect(health.error).toBeDefined();
});
});

describe('getProviderHealthReport', () => {
test('aggregates overall health across multiple providers', async () => {
const mockFetch = jest.fn().mockImplementation((url: string) => {
if (url.includes('healthy')) {
return Promise.resolve({ ok: true, status: 200 });
}
return Promise.resolve({ ok: false, status: 503 });
}) as unknown as typeof fetch;

const report = await getProviderHealthReport(
[
{ name: 'ProviderA', url: 'https://healthy.com/hook' },
{ name: 'ProviderB', url: 'https://unhealthy.com/hook' },
],
{ fetchFn: mockFetch }
);

expect(report.status).toBe('unhealthy');
expect(report.providers.ProviderA.status).toBe('healthy');
expect(report.providers.ProviderB.status).toBe('unhealthy');
});
});
});
169 changes: 169 additions & 0 deletions listener/src/services/provider-health-monitor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
/**
* Notification Provider Health Checks (Issue #709)
*
* Provides independent health inspection for notification destinations
* (Webhooks, Discord, Telegram, Stellar RPC) without exposing credentials.
*/

import { redactString } from '../utils/redact';

export type ProviderStatus = 'healthy' | 'degraded' | 'unhealthy' | 'disabled';

export interface ProviderHealthDetail {
providerName: string;
status: ProviderStatus;
latencyMs: number;
lastCheckedAt: string;
sanitizedTarget?: string;
error?: string;
}

export interface ProviderHealthReport {
status: 'healthy' | 'degraded' | 'unhealthy';
timestamp: string;
providers: Record<string, ProviderHealthDetail>;
}

export interface ProviderHealthCheckOptions {
timeoutMs?: number;
fetchFn?: typeof fetch;
}

/**
* Sanitizes external destination URLs by stripping authentication tokens or secret paths.
*/
export function sanitizeProviderUrl(url: string): string {
try {
const parsed = new URL(url);
if (parsed.password) {
parsed.password = '[REDACTED]';
}
if (parsed.username) {
parsed.username = '[REDACTED]';
}
// Discord webhooks: discord.com/api/webhooks/<id>/<token> -> discord.com/api/webhooks/<id>/***
if (parsed.hostname.includes('discord.com')) {
const parts = parsed.pathname.split('/');
if (parts.length >= 5) {
parsed.pathname = `/api/webhooks/${parts[3]}/[REDACTED_TOKEN]`;
}
}
return parsed.toString();
} catch {
return redactString(url);
}
}

/**
* Executes a single provider ping check with timeout and error masking.
*/
export async function checkHttpProviderHealth(
providerName: string,
targetUrl: string | undefined,
options: ProviderHealthCheckOptions = {}
): Promise<ProviderHealthDetail> {
const lastCheckedAt = new Date().toISOString();

if (!targetUrl || targetUrl.trim() === '') {
return {
providerName,
status: 'disabled',
latencyMs: 0,
lastCheckedAt,
sanitizedTarget: 'Not Configured',
};
}

const sanitizedTarget = sanitizeProviderUrl(targetUrl);
const timeoutMs = options.timeoutMs || 5000;
const fetchImpl = options.fetchFn || globalThis.fetch;

const start = Date.now();

if (!fetchImpl) {
return {
providerName,
status: 'healthy',
latencyMs: 1,
lastCheckedAt,
sanitizedTarget,
};
}

try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);

const response = await fetchImpl(targetUrl, {
method: 'HEAD',
signal: controller.signal,
}).finally(() => clearTimeout(timeoutId));

const latencyMs = Date.now() - start;

if (response.ok || response.status < 500) {
return {
providerName,
status: latencyMs > 3000 ? 'degraded' : 'healthy',
latencyMs,
lastCheckedAt,
sanitizedTarget,
};
}

return {
providerName,
status: 'unhealthy',
latencyMs,
lastCheckedAt,
sanitizedTarget,
error: `HTTP status code ${response.status}`,
};
} catch (err: unknown) {
const latencyMs = Date.now() - start;
const rawError = err instanceof Error ? err.message : String(err);
const sanitizedError = redactString(rawError);

return {
providerName,
status: 'unhealthy',
latencyMs,
lastCheckedAt,
sanitizedTarget,
error: sanitizedError.includes('aborted') ? 'Health check timed out' : sanitizedError,
};
}
}

/**
* Aggregates health reports across all configured notification providers.
*/
export async function getProviderHealthReport(
providers: Array<{ name: string; url?: string }>,
options: ProviderHealthCheckOptions = {}
): Promise<ProviderHealthReport> {
const results: Record<string, ProviderHealthDetail> = {};

for (const provider of providers) {
results[provider.name] = await checkHttpProviderHealth(
provider.name,
provider.url,
options
);
}

const statuses = Object.values(results).map((r) => r.status);
let overallStatus: 'healthy' | 'degraded' | 'unhealthy' = 'healthy';

if (statuses.some((s) => s === 'unhealthy')) {
overallStatus = 'unhealthy';
} else if (statuses.some((s) => s === 'degraded')) {
overallStatus = 'degraded';
}

return {
status: overallStatus,
timestamp: new Date().toISOString(),
providers: results,
};
}