Skip to content
Merged
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
3 changes: 3 additions & 0 deletions listener/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
2 changes: 2 additions & 0 deletions listener/src/api/events-server.health.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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$/);
});
Expand Down
2 changes: 2 additions & 0 deletions listener/src/api/events-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -398,6 +399,7 @@ async function buildHealthResponse(options: EventsServerOptions): Promise<Health
status: overallStatus,
version: APP_VERSION,
timestamp: new Date().toISOString(),
uptimeSeconds: process.uptime(),
services: {
stellarRpc,
discord,
Expand Down
3 changes: 3 additions & 0 deletions listener/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ async function main() {

healthMonitor = new NotificationHealthMonitor(null, getWorkerManager(), {
repository,
getLastSuccessfulPoll: () => subscriber?.getLastSuccessfulPoll() ?? null,
});

getUptimeMs: () => Date.now() - PROCESS_START_TIME,
});

Expand Down
17 changes: 7 additions & 10 deletions listener/src/services/event-subscriber.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -525,4 +518,8 @@ export class EventSubscriber {
retryQueue: this.retryQueue?.getMetrics() || null,
};
}
}

getLastSuccessfulPoll(): number | null {
return this.lastSuccessfulPollAt;
}
}
8 changes: 8 additions & 0 deletions listener/src/services/notification-health-monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Expand All @@ -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;
Expand All @@ -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);
}

Expand Down Expand Up @@ -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,
};
Expand Down