From a9923fed4ed468dd8de1753d81631e58d409cda0 Mon Sep 17 00:00:00 2001 From: Kristina <7614497+khjrtbrg@users.noreply.github.com> Date: Fri, 6 Jun 2025 11:27:57 +0200 Subject: [PATCH] Merge pull request #26198 from overleaf/jpa-recurly-metrics [web] add metrics for recurly API usage GitOrigin-RevId: 89840829f86ce1ff750d57f3445f279f4b151d6f --- .../Features/Subscription/RecurlyClient.js | 21 +++++++++- .../Features/Subscription/RecurlyMetrics.js | 38 +++++++++++++++++++ .../Features/Subscription/RecurlyWrapper.js | 9 ++++- .../acceptance/src/mocks/MockRecurlyApi.mjs | 17 +++++++++ 4 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 services/web/app/src/Features/Subscription/RecurlyMetrics.js diff --git a/services/web/app/src/Features/Subscription/RecurlyClient.js b/services/web/app/src/Features/Subscription/RecurlyClient.js index 753d49ba0f..b5af796bb2 100644 --- a/services/web/app/src/Features/Subscription/RecurlyClient.js +++ b/services/web/app/src/Features/Subscription/RecurlyClient.js @@ -22,6 +22,7 @@ const { MissingBillingInfoError, SubtotalLimitExceededError, } = require('./Errors') +const RecurlyMetrics = require('./RecurlyMetrics') /** * @import { PaymentProviderSubscriptionChangeRequest } from './PaymentProviderEntities' @@ -29,10 +30,28 @@ const { * @import { PaymentMethod } from './types' */ +class RecurlyClientWithErrorHandling extends recurly.Client { + /** + * @param {import('recurly/lib/recurly/Http').Response} response + * @return {Error | null} + * @private + */ + _errorFromResponse(response) { + RecurlyMetrics.recordMetrics( + response.status, + response.rateLimit, + response.rateLimitRemaining, + response.rateLimitReset.getTime() + ) + // @ts-ignore + return super._errorFromResponse(response) + } +} + const recurlySettings = Settings.apis.recurly const recurlyApiKey = recurlySettings ? recurlySettings.apiKey : undefined -const client = new recurly.Client(recurlyApiKey) +const client = new RecurlyClientWithErrorHandling(recurlyApiKey) /** * Get account for a given user diff --git a/services/web/app/src/Features/Subscription/RecurlyMetrics.js b/services/web/app/src/Features/Subscription/RecurlyMetrics.js new file mode 100644 index 0000000000..1b709d7dc4 --- /dev/null +++ b/services/web/app/src/Features/Subscription/RecurlyMetrics.js @@ -0,0 +1,38 @@ +const Metrics = require('@overleaf/metrics') + +/** + * @param {number} status + * @param {number} rateLimit + * @param {number} rateLimitRemaining + * @param {number} rateLimitReset + */ +function recordMetrics(status, rateLimit, rateLimitRemaining, rateLimitReset) { + Metrics.inc('recurly_request', 1, { status }) + const metrics = { rateLimit, rateLimitRemaining, rateLimitReset } + for (const [method, v] of Object.entries(metrics)) { + if (Number.isNaN(v)) continue + Metrics.gauge('recurly_request_rate_limiting', v, 1, { method }) + } +} + +/** + * @param {Response} response + */ +function recordMetricsFromResponse(response) { + const rateLimit = parseInt( + response.headers.get('X-RateLimit-Limit') || '', + 10 + ) + const rateLimitRemaining = parseInt( + response.headers.get('X-RateLimit-Remaining') || '', + 10 + ) + const rateLimitReset = + parseInt(response.headers.get('X-RateLimit-Reset') || '', 10) * 1000 + recordMetrics(response.status, rateLimit, rateLimitRemaining, rateLimitReset) +} + +module.exports = { + recordMetrics, + recordMetricsFromResponse, +} diff --git a/services/web/app/src/Features/Subscription/RecurlyWrapper.js b/services/web/app/src/Features/Subscription/RecurlyWrapper.js index 234f094ae0..d5c2369009 100644 --- a/services/web/app/src/Features/Subscription/RecurlyWrapper.js +++ b/services/web/app/src/Features/Subscription/RecurlyWrapper.js @@ -9,6 +9,7 @@ const logger = require('@overleaf/logger') const Errors = require('../Errors/Errors') const SubscriptionErrors = require('./Errors') const { callbackify } = require('@overleaf/promise-utils') +const RecurlyMetrics = require('./RecurlyMetrics') /** * Updates the email address of a Recurly account @@ -417,9 +418,15 @@ const promises = { } try { - return await fetchStringWithResponse(fetchUrl, fetchOptions) + const { body, response } = await fetchStringWithResponse( + fetchUrl, + fetchOptions + ) + RecurlyMetrics.recordMetricsFromResponse(response) + return { body, response } } catch (error) { if (error instanceof RequestFailedError) { + RecurlyMetrics.recordMetricsFromResponse(error.response) if (error.response.status === 404 && expect404) { return { response: error.response, body: null } } else if (error.response.status === 422 && expect422) { diff --git a/services/web/test/acceptance/src/mocks/MockRecurlyApi.mjs b/services/web/test/acceptance/src/mocks/MockRecurlyApi.mjs index c1e7c2aa8b..091ff62ad5 100644 --- a/services/web/test/acceptance/src/mocks/MockRecurlyApi.mjs +++ b/services/web/test/acceptance/src/mocks/MockRecurlyApi.mjs @@ -7,6 +7,9 @@ class MockRecurlyApi extends AbstractMockApi { this.mockSubscriptions = [] this.redemptions = {} this.coupons = {} + this.rateLimitResetSeconds = Math.ceil( + (Date.now() + 24 * 60 * 60 * 1000) / 1000 + ) } addMockSubscription(recurlySubscription) { @@ -25,7 +28,21 @@ class MockRecurlyApi extends AbstractMockApi { ) } + getRateLimitHeaders() { + return { + 'X-RateLimit-Limit': 1000, + 'X-RateLimit-Remaining': 999, + 'X-RateLimit-Reset': this.rateLimitResetSeconds, + } + } + applyRoutes() { + this.app.use((req, res, next) => { + for (const [name, v] of Object.entries(this.getRateLimitHeaders())) { + res.setHeader(name, v) + } + next() + }) this.app.get('/subscriptions/:id', (req, res) => { const subscription = this.getMockSubscriptionById(req.params.id) if (!subscription) {