diff --git a/listener/API.md b/listener/API.md index 93febf9..10aba37 100644 --- a/listener/API.md +++ b/listener/API.md @@ -31,12 +31,15 @@ For a centralized list of API errors, causes, examples, and troubleshooting step Returns the operational status of all service dependencies. +The `uptimeSeconds` field reports the listener process uptime in seconds, measured from process startup. + **Response `200`** — all systems operational (or Discord degraded but Stellar RPC healthy) ```json { "status": "ok", "timestamp": "2024-06-20T14:00:00.000Z", + "uptimeSeconds": 123.45, "services": { "stellarRpc": { "status": "ok", "latencyMs": 42 }, "discord": { "status": "ok", "latencyMs": 87 }, diff --git a/listener/src/api/events-server.health.test.ts b/listener/src/api/events-server.health.test.ts index 7b94b89..ec96b60 100644 --- a/listener/src/api/events-server.health.test.ts +++ b/listener/src/api/events-server.health.test.ts @@ -107,6 +107,8 @@ describe('GET /health', () => { expect(health.services.database.status).toBe('ok'); expect(health.services.eventRegistry).toEqual({ status: 'ok', eventCount: 5 }); expect(health.timestamp).toBeDefined(); + expect(typeof health.uptimeSeconds).toBe('number'); + expect(health.uptimeSeconds).toBeGreaterThanOrEqual(0); // version field is included in every health response (#624) expect(health.version).toMatch(/^\d+\.\d+\.\d+.*$|^unknown$/); }); diff --git a/listener/src/api/events-server.ts b/listener/src/api/events-server.ts index 83f47a6..20fe97d 100644 --- a/listener/src/api/events-server.ts +++ b/listener/src/api/events-server.ts @@ -118,6 +118,7 @@ interface HealthResponse { /** Semver string sourced from listener/package.json, e.g. "1.0.0". */ version: string; timestamp: string; + uptimeSeconds: number; services: { stellarRpc: ServiceHealth; discord: ServiceHealth; @@ -398,6 +399,7 @@ async function buildHealthResponse(options: EventsServerOptions): Promise subscriber?.getLastSuccessfulPoll() ?? null, + }); + getUptimeMs: () => Date.now() - PROCESS_START_TIME, }); diff --git a/listener/src/services/event-subscriber.ts b/listener/src/services/event-subscriber.ts index 9134b25..225b331 100644 --- a/listener/src/services/event-subscriber.ts +++ b/listener/src/services/event-subscriber.ts @@ -28,15 +28,7 @@ export class EventSubscriber { private deduplicationService: EventDeduplicationService | null = null; private eventQueue: EventProcessingQueue | null = null; private expirationService: NotificationExpirationService | null = null; - /** - * Cached backfill start ledger, resolved once on the first cold-start fetch - * for any contract that has no stored cursor. Re-used for all contracts in - * the same session so they share a consistent baseline. - * - * `null` → not yet resolved (or backfill limit is disabled) - * `number` → resolved start ledger (>= 1) - */ - private backfillStartLedger: number | null = null; + private lastSuccessfulPollAt: number | null = null; constructor(config: Config, deduplicationService?: EventDeduplicationService) { this.config = config; @@ -93,6 +85,7 @@ export class EventSubscriber { try { await this.checkForEvents(requestId); this.reconnectAttempts = 0; + this.lastSuccessfulPollAt = Date.now(); const durationMs = Date.now() - pollStart; pollingMetrics.record(durationMs, true); @@ -525,4 +518,8 @@ export class EventSubscriber { retryQueue: this.retryQueue?.getMetrics() || null, }; } -} + + getLastSuccessfulPoll(): number | null { + return this.lastSuccessfulPollAt; + } +} \ No newline at end of file diff --git a/listener/src/services/notification-health-monitor.ts b/listener/src/services/notification-health-monitor.ts index 2771d21..d862d56 100644 --- a/listener/src/services/notification-health-monitor.ts +++ b/listener/src/services/notification-health-monitor.ts @@ -52,6 +52,7 @@ export interface HealthReport { queue: QueueHealth; workers: WorkerHealth; registry: RegistryHealth; + lastSuccessfulPollAt: string | null; /** Process uptime in milliseconds since startup. */ uptimeMs: number; polling: PollingHealth; @@ -68,6 +69,7 @@ export interface NotificationHealthMonitorOptions { now?: () => number; /** Optional repository used to surface DLQ depth in the health report. */ repository?: ScheduledNotificationRepository | null; + getLastSuccessfulPoll?: () => number | null; /** Function to calculate uptime in milliseconds. */ getUptimeMs?: () => number; } @@ -84,6 +86,7 @@ export class NotificationHealthMonitor { private readonly stallThresholdCycles: number; private readonly maxProcessingDelayMs: number; private readonly now: () => number; + private readonly getLastSuccessfulPoll: () => number | null; private readonly getUptimeMs: () => number; private queue: EventProcessingQueue | null; @@ -110,6 +113,7 @@ export class NotificationHealthMonitor { this.stallThresholdCycles = options.stallThresholdCycles ?? 3; this.maxProcessingDelayMs = options.maxProcessingDelayMs ?? 60_000; this.now = options.now ?? Date.now; + this.getLastSuccessfulPoll = options.getLastSuccessfulPoll ?? (() => null); this.getUptimeMs = options.getUptimeMs ?? (() => 0); } @@ -151,12 +155,16 @@ export class NotificationHealthMonitor { registryHealth.status, ); + const lastSuccessfulPollMs = this.getLastSuccessfulPoll(); + const lastSuccessfulPollAt = lastSuccessfulPollMs !== null ? new Date(lastSuccessfulPollMs).toISOString() : null; + const report: HealthReport = { status: overallStatus, timestamp: new Date(this.now()).toISOString(), queue: queueHealth, workers: workerHealth, registry: registryHealth, + lastSuccessfulPollAt, uptimeMs: this.getUptimeMs(), polling: pollingHealth, };