diff --git a/contract/contracts/hello-world/src/lib.rs b/contract/contracts/hello-world/src/lib.rs index 16c4509..e7de084 100644 --- a/contract/contracts/hello-world/src/lib.rs +++ b/contract/contracts/hello-world/src/lib.rs @@ -392,6 +392,8 @@ impl AutoShareContract { /// Emits a `BatchProcessingCompleted` event for off-chain listeners. pub fn emit_batch_completed(env: Env, batch_id: BytesN<32>, processed_count: u32) { autoshare_logic::emit_batch_completed(env, batch_id, processed_count).unwrap(); + } + // ============================================================================ // Batch Notification Creation // ============================================================================ diff --git a/listener/package-lock.json b/listener/package-lock.json index 3b1d703..0389039 100644 --- a/listener/package-lock.json +++ b/listener/package-lock.json @@ -63,7 +63,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -1409,7 +1408,6 @@ "dev": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.26" @@ -1827,7 +1825,6 @@ "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } @@ -1941,7 +1938,6 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -2170,7 +2166,6 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2683,7 +2678,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -3492,7 +3486,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -4840,7 +4833,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -7401,7 +7393,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -7508,7 +7499,6 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/listener/src/services/batch-validation-service.ts b/listener/src/services/batch-validation-service.ts index ea1fa0f..dd53933 100644 --- a/listener/src/services/batch-validation-service.ts +++ b/listener/src/services/batch-validation-service.ts @@ -187,7 +187,7 @@ export class BatchValidationService { } else { invalidEntries.push({ index, - error: validation.error, + error: validation.error ?? '', }); } }); diff --git a/listener/src/services/notification-retry-queue.test.ts b/listener/src/services/notification-retry-queue.test.ts index bc2b8b9..59c610a 100644 --- a/listener/src/services/notification-retry-queue.test.ts +++ b/listener/src/services/notification-retry-queue.test.ts @@ -1,6 +1,10 @@ import { xdr } from '@stellar/stellar-sdk'; import * as StellarSDK from '@stellar/stellar-sdk'; import { NotificationRetryQueue, NotificationFn } from './notification-retry-queue'; +import { + NotificationAnalyticsAggregator, + setNotificationAnalyticsAggregator, +} from './notification-analytics-aggregator'; jest.mock('../utils/logger', () => ({ __esModule: true, @@ -296,4 +300,64 @@ describe('NotificationRetryQueue', () => { queue.stop(); }); }); + + describe('analytics success counter (regression: no duplicate-counting)', () => { + let aggregator: NotificationAnalyticsAggregator; + + beforeEach(() => { + aggregator = new NotificationAnalyticsAggregator(); + setNotificationAnalyticsAggregator(aggregator); + }); + + afterEach(() => { + setNotificationAnalyticsAggregator(null); + }); + + it('increments success exactly once when an operation succeeds after multiple failed retries', async () => { + // Fails on attempts 1 and 2, succeeds on attempt 3. + let callCount = 0; + const notificationFn: NotificationFn = jest.fn().mockImplementation(async () => { + callCount++; + return callCount >= 3; + }); + + const queue = new NotificationRetryQueue(notificationFn, { + baseDelayMs: 100, + maxRetries: 5, + processIntervalMs: 50, + jitter: false, + }); + queue.start(); + + queue.enqueue(createMockEvent({ id: 'evt-multi-retry' }), mockContractConfig, 'req-regression'); + + const flush = async () => { for (let i = 0; i < 8; i++) await Promise.resolve(); }; + + // Attempt 1 at t=100 (base delay), fails → retry recorded + jest.advanceTimersByTime(100); + await flush(); + // Attempt 2 at t=300 (100 + 100*2^1), fails → retry recorded + jest.advanceTimersByTime(200); + await flush(); + // Attempt 3 at t=700 (300 + 100*2^2), succeeds → success recorded + jest.advanceTimersByTime(400); + await flush(); + + queue.stop(); + + const snap = aggregator.snapshot(); + + // The notification fn was called exactly 3 times + expect(notificationFn).toHaveBeenCalledTimes(3); + + // Success must be exactly 1 — not 0 (missing) and not >1 (duplicate) + expect(snap.overall.success).toBe(1); + + // Three retry-attempt events were emitted (one per call before success is known) + expect(snap.overall.retry).toBe(3); + + // No failure outcome — the operation ultimately succeeded + expect(snap.overall.failure).toBe(0); + }); + }); }); diff --git a/listener/src/services/notification-retry-queue.ts b/listener/src/services/notification-retry-queue.ts index e24099c..7efc238 100644 --- a/listener/src/services/notification-retry-queue.ts +++ b/listener/src/services/notification-retry-queue.ts @@ -145,6 +145,13 @@ export class NotificationRetryQueue { if (success) { this.queuedFingerprints.delete(fingerprint); + this.analytics?.record({ + notificationType: NotificationType.DISCORD, + contractAddress: item.contractConfig.address, + outcome: 'success', + durationMs: Date.now() - retryStart, + timestamp: Date.now(), + }); logger.info('Retry succeeded', { requestId: item.requestId, eventId: item.event.id,