From c6b22d7dd6d86f3795eab6d4390eacc581d84c46 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 3 Oct 2026 10:55:34 +0300 Subject: [PATCH 1/7] feat: add scoped analytics get command --- README.md | 10 ++ package.json | 3 + src/commands/analytics/get.ts | 43 +++++++ src/services/analytics.ts | 222 ++++++++++++++++++++++++++++++++ test/commands/analytics.test.ts | 78 +++++++++++ 5 files changed, 356 insertions(+) create mode 100644 src/commands/analytics/get.ts create mode 100644 src/services/analytics.ts create mode 100644 test/commands/analytics.test.ts diff --git a/README.md b/README.md index f83edf2..ece2cb6 100644 --- a/README.md +++ b/README.md @@ -2443,3 +2443,13 @@ project. Human output labels unmeasured duration as `unknown`. `calls view` reads one stored call. Its JSON output includes bounded event history, participant and connection lifetimes, and allowlisted diagnostics. It does not retrieve media or webhook deliveries. + +### WhatsApp analytics + +`polymorfa analytics get --project PROJECT_ID --json` reads sent and received +activity, completed 24-hour read and reply cohorts, response times and number +connection vitals. Use `--since` and `--until` with RFC 3339 timestamps (up to +366 days), and `--session-id` with a Number record UUID. Requires `sessions:read`; +a project token stays inside its project. A team owner or admin enables +Analytics in the Console. Disabled results contain no business data; missing +coverage has null rates. Read receipts do not prove an app open. diff --git a/package.json b/package.json index e36bc80..5b0189d 100644 --- a/package.json +++ b/package.json @@ -326,6 +326,9 @@ }, "cloud:group:join-requests": { "description": "Review join requests for Official groups that require approval." + }, + "analytics": { + "description": "Read WhatsApp number activity and operational vitals." } } }, diff --git a/src/commands/analytics/get.ts b/src/commands/analytics/get.ts new file mode 100644 index 0000000..79d2f1a --- /dev/null +++ b/src/commands/analytics/get.ts @@ -0,0 +1,43 @@ +import {Flags} from '@oclif/core' +import {BaseCommand} from '../../base-command.js' +import { + analyticsQuery, + getAnalytics, + renderAnalytics, +} from '../../services/analytics.js' +export default class AnalyticsGet extends BaseCommand { + static override description = + 'Read WhatsApp number activity, completed 24-hour read and reply rates, response times and connection vitals. Requires sessions:read and Analytics enabled in the Console. Project credentials read only their project.' + static override summary = 'Read WhatsApp number analytics.' + static override examples = [ + '<%= config.bin %> analytics get --project PROJECT_ID --json', + '<%= config.bin %> analytics get --since 2026-10-01T00:00:00Z --until 2026-10-03T00:00:00Z', + ] + static override flags = { + since: Flags.string({ + description: 'Start time in RFC 3339; defaults to 30 days ago.', + }), + until: Flags.string({ + description: 'End time in RFC 3339; defaults to now.', + }), + 'session-id': Flags.string({ + description: 'Limit to one number record UUID.', + }), + } + async run(): Promise { + const {flags} = await this.parse(AnalyticsGet) + const query = analyticsQuery({ + ...(flags.since === undefined ? {} : {since: flags.since}), + ...(flags.until === undefined ? {} : {until: flags.until}), + ...(flags['session-id'] === undefined + ? {} + : {sessionId: flags['session-id']}), + }) + const session = await this.developerSession({ + ...flags, + owner: flags.owner ?? (flags.project ? 'project' : 'organization'), + }) + const result = await getAnalytics(session.client, query) + this.writeValue(result, renderAnalytics(result.data), flags) + } +} diff --git a/src/services/analytics.ts b/src/services/analytics.ts new file mode 100644 index 0000000..43b2d34 --- /dev/null +++ b/src/services/analytics.ts @@ -0,0 +1,222 @@ +// Platform analytics contract: polymorfa/polymorfa@c582f0c25efd24d6e5939b6d49b22abe57e26eca +import { + PolymorfaServerError, + PolymorfaValidationError, + type ClientBase, + type ClientOwner, + type QueryValue, +} from '@polymorfa/sdk' +import {optionalTimeRange} from '../input/time.js' +export interface WhatsAppBusinessSegment { + dimension: + | 'message_type' + | 'text_band' + | 'origin' + | 'calling_code' + | 'customer_devices' + key: string + sendAttempts: number + sent: number + sendFailures: number + sendFailureRate: number | null + completedConversations: number + deliveredConversations: number + readConversations: number + repliedConversations: number + deliveryRate: number | null + readRate: number | null + replyRate: number | null + averageCustomerReplyMs: number | null + readRateInterval95: [number, number] | null + replyRateInterval95: [number, number] | null + readRateDifference: number | null + replyRateDifference: number | null + shareOfConversations: number | null + shareOfSends: number | null +} +export interface WhatsAppEngagement { + windowHours: 24 + completedConversations: number + deliveredConversations: number + readConversations: number + repliedConversations: number + deliveryRate: number | null + readRate: number | null + replyRate: number | null + averageCustomerReplyMs: number | null + complete: boolean + droppedConversations: number + droppedRecords: number + droppedReceiptJoins: number +} +export interface WhatsAppMetrics { + measured: boolean + observedHours: number + lastObservedAt: number | null + outgoingMessages: number + incomingMessages: number + sendAttempts: number + sendFailures: number + sendFailureRate: number | null + businessReplies: number + averageBusinessResponseMs: number | null + onlineMs: number + disconnects: number + connectFailures: number + streamErrors: number + keepaliveTimeouts: number + engagement: WhatsAppEngagement + messageAnalysis: {complete: boolean; segments: WhatsAppBusinessSegment[]} + customerActivity: { + observed: boolean + onlineSignals: number + offlineSignals: number + typingSignals: number + } + accountActivity: { + observed: boolean + primaryPhoneActivitySignals: number + primaryPhoneActivePeriods: number + primaryPhoneMessages: number + otherDeviceMessages: number + primaryPhoneReplies: number + otherDeviceReplies: number + averagePrimaryPhoneResponseMs: number | null + averageOtherDeviceResponseMs: number | null + lastPrimaryPhoneAt: number | null + } + responseQueue: { + awaitingReply: number + oldestWaitingMs: number + observedAt: number + complete: boolean + } | null +} +export interface WhatsAppAnalytics { + enabled: boolean + period: {start: number; end: number} + requestVitals: { + requests: number + failures: number + errorRate: number | null + } + summary: + | (WhatsAppMetrics & { + totalNumbers: number + measuredNumbers: number + connectedNumbers: number + }) + | null + numbers: (WhatsAppMetrics & { + sessionId: string + projectId: string + projectName: string + name: string + backend: string + status: string + })[] + series: { + customerOnlineSignals: number + customerTypingSignals: number + primaryPhoneMessages: number + otherDeviceMessages: number + primaryPhoneReplies: number + phoneActivePeriods: number + sessionId: string + ts: number + outgoingMessages: number + incomingMessages: number + sendFailures: number + businessReplies: number + averageBusinessResponseMs: number | null + onlineMs: number + disconnects: number + }[] +} +export interface AnalyticsSettings { + enabled: boolean +} + +export function analyticsQuery(input: { + since?: string + until?: string + sessionId?: string +}): Record { + optionalTimeRange(input.since, input.until) + const query: Record = {} + if (input.since !== undefined) query.start = Date.parse(input.since) + if (input.until !== undefined) query.end = Date.parse(input.until) + if ( + input.since !== undefined && + input.until !== undefined && + Date.parse(input.until) - Date.parse(input.since) > 366 * 86_400_000 + ) + throw new PolymorfaValidationError('Choose a range of up to 366 days.', { + code: 'invalid_analytics_range', + }) + if (input.sessionId !== undefined) { + if ( + !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + input.sessionId, + ) + ) + throw new PolymorfaValidationError('--session-id must be a UUID.', { + code: 'invalid_analytics_filter', + }) + query.sessionId = input.sessionId + } + return query +} +export async function getAnalytics( + client: ClientBase, + query: Record, +) { + // Project raw paths are confined by the SDK to /platform/projects/{id}. + const response = await client.raw.request<{data: WhatsAppAnalytics}>({ + method: 'GET', + path: client.projectId === null ? '/platform/analytics' : '/analytics', + query, + }) + const data = response.data?.data + if ( + !data || + typeof data.enabled !== 'boolean' || + !data.requestVitals || + !Array.isArray(data.numbers) || + !Array.isArray(data.series) || + (data.enabled && + (!data.summary || typeof data.summary.measured !== 'boolean')) + ) + throw new PolymorfaServerError('The API returned invalid analytics.', { + code: 'invalid_response', + }) + return { + data, + metadata: { + status: response.metadata.status, + attempts: response.metadata.attempts, + ...(response.metadata.requestId === undefined + ? {} + : {requestId: response.metadata.requestId}), + }, + } +} +export function renderAnalytics(data: WhatsAppAnalytics) { + const rate = (v: number | null) => + v === null ? 'Not measured' : `${(v * 100).toFixed(1)}%` + const requests = `API errors: ${rate(data.requestVitals.errorRate)} (${data.requestVitals.failures}/${data.requestVitals.requests})` + if (!data.enabled) + return `Analytics is disabled. A team owner or admin can enable it in the Console.\n${requests}` + const m = data.summary! + if (!m.measured) return `No number measurements in this period.\n${requests}` + return [ + `Sent: ${m.outgoingMessages}`, + `Received (direct): ${m.incomingMessages}`, + `Read rate (24h): ${rate(m.engagement.readRate)}`, + `Reply rate (24h): ${rate(m.engagement.replyRate)}`, + `Send failure rate: ${rate(m.sendFailureRate)}`, + `Disconnects: ${m.disconnects}`, + `Connection failures: ${m.connectFailures}`, + requests, + ].join('\n') +} diff --git a/test/commands/analytics.test.ts b/test/commands/analytics.test.ts new file mode 100644 index 0000000..c7cd271 --- /dev/null +++ b/test/commands/analytics.test.ts @@ -0,0 +1,78 @@ +import {runCommand} from '@oclif/test' +import {afterEach, expect, test} from 'vitest' +import { + startHttpServer, + withEnvironment, + type TestHttpServer, +} from '../helpers/http-server.js' +const servers: TestHttpServer[] = [] +afterEach(async () => { + await Promise.all(servers.splice(0).map((s) => s.close())) +}) +const PROJECT = '22222222-2222-4222-8222-222222222222' +const body = { + enabled: false, + period: {start: 0, end: 1}, + requestVitals: {requests: 20, failures: 1, errorRate: 0.05}, + summary: null, + numbers: [], + series: [], +} +test('runs the JSON analytics command against the project-confined public route', async () => { + const server = await startHttpServer({body: {data: body}}) + servers.push(server) + const result = await withEnvironment( + { + POLYMORFA_API_KEY: undefined, + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_PROJECT_TOKEN: + 'pmfa_pt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_BASE_URL: server.baseUrl, + }, + () => + runCommand([ + 'analytics', + 'get', + '--project', + PROJECT, + '--since', + '2026-10-01T00:00:00Z', + '--until', + '2026-10-03T00:00:00Z', + '--json', + ]), + ) + expect(result.error, result.stderr).toBeUndefined() + expect(JSON.parse(result.stdout).data.enabled).toBe(false) + expect(server.requests).toHaveLength(1) + expect(server.requests[0]?.url).toContain( + `/platform/projects/${PROJECT}/analytics`, + ) + expect(server.requests[0]?.url).toContain( + `start=${Date.parse('2026-10-01T00:00:00Z')}`, + ) +}) +test('rejects an invalid range before making a request', async () => { + const server = await startHttpServer({body: {data: body}}) + servers.push(server) + const result = await withEnvironment( + { + POLYMORFA_API_KEY: + 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_PROJECT_TOKEN: undefined, + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_BASE_URL: server.baseUrl, + }, + () => + runCommand([ + 'analytics', + 'get', + '--since', + '2026-10-03T00:00:00Z', + '--until', + '2026-10-01T00:00:00Z', + ]), + ) + expect(result.error).toBeDefined() + expect(server.requests).toHaveLength(0) +}) From 62185a621765d7b4e15f267feff25baf6a1b360c Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 3 Oct 2026 11:33:32 +0300 Subject: [PATCH 2/7] Report call vitals alongside message analytics --- README.md | 4 +- src/commands/analytics/get.ts | 2 +- src/services/analytics.ts | 83 +++++++++++++++++++++++++++++---- test/commands/analytics.test.ts | 44 +++++++++++++++++ 4 files changed, 122 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index ece2cb6..4d9d367 100644 --- a/README.md +++ b/README.md @@ -2448,7 +2448,9 @@ does not retrieve media or webhook deliveries. `polymorfa analytics get --project PROJECT_ID --json` reads sent and received activity, completed 24-hour read and reply cohorts, response times and number -connection vitals. Use `--since` and `--until` with RFC 3339 timestamps (up to +connection vitals, call outcomes, pickup times and missed-call follow-up. +Call results remain available without messaging telemetry. `--json` includes +per-number call series, quality coverage and error breakdowns. Use `--since` and `--until` with RFC 3339 timestamps (up to 366 days), and `--session-id` with a Number record UUID. Requires `sessions:read`; a project token stays inside its project. A team owner or admin enables Analytics in the Console. Disabled results contain no business data; missing diff --git a/src/commands/analytics/get.ts b/src/commands/analytics/get.ts index 79d2f1a..089fdc5 100644 --- a/src/commands/analytics/get.ts +++ b/src/commands/analytics/get.ts @@ -7,7 +7,7 @@ import { } from '../../services/analytics.js' export default class AnalyticsGet extends BaseCommand { static override description = - 'Read WhatsApp number activity, completed 24-hour read and reply rates, response times and connection vitals. Requires sessions:read and Analytics enabled in the Console. Project credentials read only their project.' + 'Read WhatsApp number activity, completed 24-hour read and reply rates, response times, call outcomes and connection vitals. Requires sessions:read and Analytics enabled in the Console. Project credentials read only their project.' static override summary = 'Read WhatsApp number analytics.' static override examples = [ '<%= config.bin %> analytics get --project PROJECT_ID --json', diff --git a/src/services/analytics.ts b/src/services/analytics.ts index 43b2d34..c02dc39 100644 --- a/src/services/analytics.ts +++ b/src/services/analytics.ts @@ -7,6 +7,53 @@ import { type QueryValue, } from '@polymorfa/sdk' import {optionalTimeRange} from '../input/time.js' +export interface CallOutcomeMetrics { + total: number + answered: number + missed: number + declined: number + failed: number + ringing: number + answerRate: number | null + talkSeconds: number + timedAnswered: number + timedPickup: number + averageTalkSeconds: number | null + medianTalkSeconds: number | null + p95TalkSeconds: number | null + averagePickupMs: number | null + p95PickupMs: number | null + shortAnswered: number + video: number +} +export interface CallBusinessMetrics extends CallOutcomeMetrics { + directions: {inbound: CallOutcomeMetrics; outbound: CallOutcomeMetrics} + mediaQuality: { + measuredCalls: number + averageJitterMs: number | null + averageRttMs: number | null + packetsLost: number | null + } + appQuality: { + measuredCalls: number + averageJitterMs: number | null + averageRttMs: number | null + packetLossRate: number | null + reconnects: number | null + } + endReasons: {code: string; count: number}[] + appErrors: {code: string; count: number}[] + transports: {code: string; count: number}[] + multiParticipantCalls: number + followUp: { + eligibleMissed: number + returnedWithin24h: number + rate: number | null + averageDelayMs: number | null + pendingWindow: number + unknownContact: number + } +} export interface WhatsAppBusinessSegment { dimension: | 'message_type' @@ -50,6 +97,7 @@ export interface WhatsAppEngagement { droppedReceiptJoins: number } export interface WhatsAppMetrics { + calls: CallBusinessMetrics measured: boolean observedHours: number lastObservedAt: number | null @@ -93,6 +141,7 @@ export interface WhatsAppMetrics { } | null } export interface WhatsAppAnalytics { + callSeries: (CallOutcomeMetrics & {sessionId: string; ts: number})[] enabled: boolean period: {start: number; end: number} requestVitals: { @@ -184,8 +233,14 @@ export async function getAnalytics( !data.requestVitals || !Array.isArray(data.numbers) || !Array.isArray(data.series) || + !Array.isArray(data.callSeries) || (data.enabled && - (!data.summary || typeof data.summary.measured !== 'boolean')) + (!data.summary || + typeof data.summary.measured !== 'boolean' || + !data.summary.calls || + !Number.isSafeInteger(data.summary.calls.total) || + !data.summary.calls.directions?.inbound || + !data.summary.calls.directions?.outbound)) ) throw new PolymorfaServerError('The API returned invalid analytics.', { code: 'invalid_response', @@ -208,15 +263,25 @@ export function renderAnalytics(data: WhatsAppAnalytics) { if (!data.enabled) return `Analytics is disabled. A team owner or admin can enable it in the Console.\n${requests}` const m = data.summary! - if (!m.measured) return `No number measurements in this period.\n${requests}` return [ - `Sent: ${m.outgoingMessages}`, - `Received (direct): ${m.incomingMessages}`, - `Read rate (24h): ${rate(m.engagement.readRate)}`, - `Reply rate (24h): ${rate(m.engagement.replyRate)}`, - `Send failure rate: ${rate(m.sendFailureRate)}`, - `Disconnects: ${m.disconnects}`, - `Connection failures: ${m.connectFailures}`, + ...(m.measured + ? [ + `Sent: ${m.outgoingMessages}`, + `Received (direct): ${m.incomingMessages}`, + `Read rate (24h): ${rate(m.engagement.readRate)}`, + `Reply rate (24h): ${rate(m.engagement.replyRate)}`, + `Send failure rate: ${rate(m.sendFailureRate)}`, + `Disconnects: ${m.disconnects}`, + `Connection failures: ${m.connectFailures}`, + ] + : ['No message measurements in this period.']), + `Calls: ${m.calls.total}`, + `Call answer rate: ${rate(m.calls.answerRate)}`, + `Missed incoming calls: ${m.calls.directions.inbound.missed}`, + `Failed calls: ${m.calls.failed}`, + `Median call length: ${m.calls.medianTalkSeconds === null ? 'Not measured' : `${m.calls.medianTalkSeconds} s`}`, + `Average pickup: ${m.calls.averagePickupMs === null ? 'Not measured' : `${(m.calls.averagePickupMs / 1000).toFixed(1)} s`}`, + `Missed-call follow-up (24h): ${rate(m.calls.followUp.rate)} (${m.calls.followUp.returnedWithin24h}/${m.calls.followUp.eligibleMissed}; ${m.calls.followUp.pendingWindow} pending)`, requests, ].join('\n') } diff --git a/test/commands/analytics.test.ts b/test/commands/analytics.test.ts index c7cd271..05ee1ae 100644 --- a/test/commands/analytics.test.ts +++ b/test/commands/analytics.test.ts @@ -17,6 +17,7 @@ const body = { summary: null, numbers: [], series: [], + callSeries: [], } test('runs the JSON analytics command against the project-confined public route', async () => { const server = await startHttpServer({body: {data: body}}) @@ -76,3 +77,46 @@ test('rejects an invalid range before making a request', async () => { expect(result.error).toBeDefined() expect(server.requests).toHaveLength(0) }) + +test('shows call vitals when messaging measurements are unavailable', async () => { + const calls = { + total: 3, + answerRate: 0.5, + directions: {inbound: {missed: 1}, outbound: {missed: 0}}, + failed: 1, + medianTalkSeconds: 60, + averagePickupMs: 3000, + followUp: { + rate: 0.5, + returnedWithin24h: 1, + eligibleMissed: 2, + pendingWindow: 1, + }, + } + const server = await startHttpServer({ + body: { + data: { + ...body, + enabled: true, + summary: {measured: false, calls}, + callSeries: [{ts: 1, answered: 1}], + }, + }, + }) + servers.push(server) + const result = await withEnvironment( + { + POLYMORFA_API_KEY: + 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_PROJECT_TOKEN: undefined, + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_BASE_URL: server.baseUrl, + }, + () => runCommand(['analytics', 'get']), + ) + expect(result.error, result.stderr).toBeUndefined() + expect(result.stdout).toContain('Calls: 3') + expect(result.stdout).toContain('Call answer rate: 50.0%') + expect(result.stdout).toContain('Missed incoming calls: 1') + expect(result.stdout).toContain('No message measurements') +}) From 68c40a9bdbf314c352480b37f226aa1f49591dd3 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 3 Oct 2026 11:51:53 +0300 Subject: [PATCH 3/7] Pin analytics command to integrated messaging and calls contract --- src/services/analytics.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/analytics.ts b/src/services/analytics.ts index c02dc39..66f1302 100644 --- a/src/services/analytics.ts +++ b/src/services/analytics.ts @@ -1,4 +1,4 @@ -// Platform analytics contract: polymorfa/polymorfa@c582f0c25efd24d6e5939b6d49b22abe57e26eca +// Platform analytics contract: polymorfa/polymorfa@d60780445908a09d02a1f80da9b85e685c58f438 import { PolymorfaServerError, PolymorfaValidationError, From 5a954dbcad906ab1991a0365c28b8a5d12a52111 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 3 Oct 2026 12:55:03 +0300 Subject: [PATCH 4/7] Add Analytics metrics export command --- README.md | 9 +++++ src/commands/analytics/metrics.ts | 50 ++++++++++++++++++++++++ src/services/analytics.ts | 65 ++++++++++++++++++++++++++++++- test/commands/analytics.test.ts | 61 +++++++++++++++++++++++++++++ 4 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 src/commands/analytics/metrics.ts diff --git a/README.md b/README.md index 4d4b667..acba23d 100644 --- a/README.md +++ b/README.md @@ -2465,3 +2465,12 @@ per-number call series, quality coverage and error breakdowns. Use `--since` and a project token stays inside its project. A team owner or admin enables Analytics in the Console. Disabled results contain no business data; missing coverage has null rates. Read receipts do not prove an app open. + +### Collector metrics + +Source builds include `polymorfa analytics metrics --project PROJECT_ID --format openmetrics`. +It writes metrics text to stdout, or wraps the text and response metadata with `--json`. +Use `--session-id` for a number record UUID, `--window-hours` for 1–168 completed UTC +hours (default 24), and `--segments` for metadata breakdowns. Requires `sessions:read` +and the same Analytics opt-in. These are overlapping-window gauges, not counters. +[Collector setup](https://docs.polymorfa.com/console/analytics-collectors). diff --git a/src/commands/analytics/metrics.ts b/src/commands/analytics/metrics.ts new file mode 100644 index 0000000..c232297 --- /dev/null +++ b/src/commands/analytics/metrics.ts @@ -0,0 +1,50 @@ +import {Flags} from '@oclif/core' +import {BaseCommand} from '../../base-command.js' +import { + analyticsMetricsQuery, + getAnalyticsMetrics, +} from '../../services/analytics.js' + +export default class AnalyticsMetrics extends BaseCommand { + static override summary = 'Export collector-compatible analytics metrics.' + static override description = + 'Write Prometheus or OpenMetrics gauges to stdout. Requires sessions:read and Analytics enabled in the Console. Counts cover completed UTC hours and are not monotonic counters. Project credentials read only their project. Disabled Analytics returns only enablement and window metadata. Use --json for a text body and response metadata envelope.' + static override examples = [ + '<%= config.bin %> analytics metrics --project PROJECT_ID > analytics.prom', + '<%= config.bin %> analytics metrics --window-hours 24 --segments --format openmetrics', + ] + static override flags = { + 'session-id': Flags.string({ + description: 'Limit to one number record UUID.', + }), + 'window-hours': Flags.integer({ + description: 'Completed UTC hours to measure.', + default: 24, + min: 1, + max: 168, + }), + segments: Flags.boolean({ + description: 'Include bounded metadata category breakdowns.', + }), + format: Flags.string({ + description: 'Metrics representation.', + options: ['prometheus', 'openmetrics'], + default: 'prometheus', + }), + } + async run(): Promise { + const {flags} = await this.parse(AnalyticsMetrics) + const query = analyticsMetricsQuery({ + sessionId: flags['session-id'], + windowHours: flags['window-hours'], + segments: flags.segments, + format: flags.format, + }) + const session = await this.developerSession({ + ...flags, + owner: flags.owner ?? (flags.project ? 'project' : 'organization'), + }) + const result = await getAnalyticsMetrics(session.client, query) + this.writeValue(result, result.data.trimEnd(), flags) + } +} diff --git a/src/services/analytics.ts b/src/services/analytics.ts index 66f1302..132e48e 100644 --- a/src/services/analytics.ts +++ b/src/services/analytics.ts @@ -1,4 +1,4 @@ -// Platform analytics contract: polymorfa/polymorfa@d60780445908a09d02a1f80da9b85e685c58f438 +// Platform analytics contract: polymorfa/polymorfa@19cc9cd42f45e085b7757aebaf6a44e4f66115c7 import { PolymorfaServerError, PolymorfaValidationError, @@ -285,3 +285,66 @@ export function renderAnalytics(data: WhatsAppAnalytics) { requests, ].join('\n') } + +export function analyticsMetricsQuery(input: { + sessionId?: string | undefined + windowHours?: number | undefined + segments?: boolean | undefined + format?: string | undefined +}): Record { + const query = analyticsQuery( + input.sessionId === undefined ? {} : {sessionId: input.sessionId}, + ) + if ( + input.windowHours !== undefined && + (!Number.isInteger(input.windowHours) || + input.windowHours < 1 || + input.windowHours > 168) + ) + throw new PolymorfaValidationError( + 'Choose a metrics window of 1 to 168 hours.', + {code: 'invalid_analytics_range'}, + ) + const format = input.format ?? 'prometheus' + if (format !== 'prometheus' && format !== 'openmetrics') + throw new PolymorfaValidationError('Choose prometheus or openmetrics.', { + code: 'invalid_analytics_filter', + }) + return { + ...query, + windowHours: input.windowHours ?? 24, + segments: input.segments ?? false, + format, + } +} +export async function getAnalyticsMetrics( + client: ClientBase, + query: Record, +) { + const response = await client.raw.request({ + method: 'GET', + path: + client.projectId === null + ? '/platform/analytics/metrics' + : '/analytics/metrics', + query, + }) + const expected = + query.format === 'openmetrics' + ? 'application/openmetrics-text' + : 'text/plain' + if ( + typeof response.data !== 'string' || + !/^# TYPE polymorfa_analytics_enabled gauge$/m.test(response.data) || + response.metadata.headers['content-type'] + ?.split(';', 1)[0] + ?.trim() + .toLowerCase() !== expected || + (query.format === 'openmetrics' && !response.data.endsWith('# EOF\n')) + ) + throw new PolymorfaServerError( + 'The API returned invalid analytics metrics.', + {code: 'invalid_response'}, + ) + return response +} diff --git a/test/commands/analytics.test.ts b/test/commands/analytics.test.ts index 05ee1ae..e20670c 100644 --- a/test/commands/analytics.test.ts +++ b/test/commands/analytics.test.ts @@ -120,3 +120,64 @@ test('shows call vitals when messaging measurements are unavailable', async () = expect(result.stdout).toContain('Missed incoming calls: 1') expect(result.stdout).toContain('No message measurements') }) + +test('exports raw collector text through the project-confined SDK transport', async () => { + const rawBody = + '# TYPE polymorfa_analytics_enabled gauge\npolymorfa_analytics_enabled{scope="project"} 0\n# EOF\n' + const server = await startHttpServer({ + rawBody, + headers: { + 'content-type': + 'application/openmetrics-text; version=1.0.0; charset=utf-8', + }, + }) + servers.push(server) + const result = await withEnvironment( + { + POLYMORFA_API_KEY: undefined, + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_PROJECT_TOKEN: + 'pmfa_pt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_BASE_URL: server.baseUrl, + }, + () => + runCommand([ + 'analytics', + 'metrics', + '--project', + PROJECT, + '--format', + 'openmetrics', + '--segments', + '--window-hours', + '6', + ]), + ) + expect(result.error, result.stderr).toBeUndefined() + expect(result.stdout).toBe(rawBody) + expect(server.requests[0]?.url).toContain( + `/platform/projects/${PROJECT}/analytics/metrics`, + ) + expect(server.requests[0]?.url).toContain('windowHours=6') + expect(server.requests[0]?.url).toContain('segments=true') +}) + +test('rejects an HTML response instead of printing it as collector data', async () => { + const server = await startHttpServer({ + rawBody: '

Proxy failure

', + headers: {'content-type': 'text/html'}, + }) + servers.push(server) + const result = await withEnvironment( + { + POLYMORFA_API_KEY: + 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_PROJECT_TOKEN: undefined, + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_BASE_URL: server.baseUrl, + }, + () => runCommand(['analytics', 'metrics']), + ) + expect(result.error).toBeDefined() + expect(result.stdout).not.toContain('Proxy failure') +}) From 66db347c690ef59c2ae60a3fb35afb8b6b3a343f Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 3 Oct 2026 19:25:21 +0300 Subject: [PATCH 5/7] Keep Analytics JSON test lint-safe --- test/commands/analytics.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/commands/analytics.test.ts b/test/commands/analytics.test.ts index e20670c..3311c51 100644 --- a/test/commands/analytics.test.ts +++ b/test/commands/analytics.test.ts @@ -44,7 +44,9 @@ test('runs the JSON analytics command against the project-confined public route' ]), ) expect(result.error, result.stderr).toBeUndefined() - expect(JSON.parse(result.stdout).data.enabled).toBe(false) + expect(JSON.parse(result.stdout) as unknown).toMatchObject({ + data: {enabled: false}, + }) expect(server.requests).toHaveLength(1) expect(server.requests[0]?.url).toContain( `/platform/projects/${PROJECT}/analytics`, From 5bc3a6ae76d3a4236174a262690d41dbbf3483b9 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 3 Oct 2026 23:16:43 +0300 Subject: [PATCH 6/7] Include observed phone intervals in Analytics JSON output --- README.md | 6 ++++++ src/services/analytics.ts | 12 +++++++++++- test/commands/analytics.test.ts | 29 ++++++++++++++++++++++++++++- 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index acba23d..768fd35 100644 --- a/README.md +++ b/README.md @@ -2474,3 +2474,9 @@ Use `--session-id` for a number record UUID, `--window-hours` for 1–168 comple hours (default 24), and `--segments` for metadata breakdowns. Requires `sessions:read` and the same Analytics opt-in. These are overlapping-window gauges, not counters. [Collector setup](https://docs.polymorfa.com/console/analytics-collectors). + +Analytics source builds also expose completed observed phone activity spans and +quiet gaps, duration sums, sample counts and weighted averages. Ranges up to +48 hours use hourly activity buckets; longer ranges and call series use daily +UTC buckets. These signals do not establish phone online/offline or power state. +See the [measurement definitions](https://docs.polymorfa.com/console/analytics#phone-polymorfa-and-customer-activity). diff --git a/src/services/analytics.ts b/src/services/analytics.ts index 132e48e..05ea5b4 100644 --- a/src/services/analytics.ts +++ b/src/services/analytics.ts @@ -1,4 +1,4 @@ -// Platform analytics contract: polymorfa/polymorfa@19cc9cd42f45e085b7757aebaf6a44e4f66115c7 +// Platform analytics contract: polymorfa/polymorfa@e6006c2242ed8fe01abbe6b2d8db93c230508929 import { PolymorfaServerError, PolymorfaValidationError, @@ -125,6 +125,12 @@ export interface WhatsAppMetrics { observed: boolean primaryPhoneActivitySignals: number primaryPhoneActivePeriods: number + completedPhoneActivityPeriods: number + phoneActivityMs: number + averagePhoneActivityMs: number | null + phoneQuietGaps: number + phoneQuietMs: number + averagePhoneQuietMs: number | null primaryPhoneMessages: number otherDeviceMessages: number primaryPhoneReplies: number @@ -171,6 +177,10 @@ export interface WhatsAppAnalytics { otherDeviceMessages: number primaryPhoneReplies: number phoneActivePeriods: number + completedPhoneActivityPeriods: number + phoneActivityMs: number + phoneQuietGaps: number + phoneQuietMs: number sessionId: string ts: number outgoingMessages: number diff --git a/test/commands/analytics.test.ts b/test/commands/analytics.test.ts index 3311c51..616f73a 100644 --- a/test/commands/analytics.test.ts +++ b/test/commands/analytics.test.ts @@ -10,6 +10,14 @@ afterEach(async () => { await Promise.all(servers.splice(0).map((s) => s.close())) }) const PROJECT = '22222222-2222-4222-8222-222222222222' +const intervals = { + completedPhoneActivityPeriods: 2, + phoneActivityMs: 600_000, + averagePhoneActivityMs: 300_000, + phoneQuietGaps: 1, + phoneQuietMs: 1_200_000, + averagePhoneQuietMs: 1_200_000, +} const body = { enabled: false, period: {start: 0, end: 1}, @@ -100,7 +108,11 @@ test('shows call vitals when messaging measurements are unavailable', async () = data: { ...body, enabled: true, - summary: {measured: false, calls}, + summary: { + accountActivity: intervals, + measured: false, + calls, + }, callSeries: [{ts: 1, answered: 1}], }, }, @@ -121,6 +133,21 @@ test('shows call vitals when messaging measurements are unavailable', async () = expect(result.stdout).toContain('Call answer rate: 50.0%') expect(result.stdout).toContain('Missed incoming calls: 1') expect(result.stdout).toContain('No message measurements') + + const json = await withEnvironment( + { + POLYMORFA_API_KEY: + 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_PROJECT_TOKEN: undefined, + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_BASE_URL: server.baseUrl, + }, + () => runCommand(['analytics', 'get', '--json']), + ) + expect(json.error, json.stderr).toBeUndefined() + expect(JSON.parse(json.stdout) as unknown).toMatchObject({ + data: {summary: {accountActivity: intervals}}, + }) }) test('exports raw collector text through the project-confined SDK transport', async () => { From e1c4888350a9fb649f1a10a7b0fb30d92a061d19 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 4 Oct 2026 04:21:31 +0300 Subject: [PATCH 7/7] Use published Analytics SDK methods and shared types --- README.md | 36 +++++- package-lock.json | 8 +- package.json | 2 +- src/services/analytics.ts | 239 +++----------------------------------- 4 files changed, 48 insertions(+), 237 deletions(-) diff --git a/README.md b/README.md index 49a22a4..201b95f 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ prerelease in `package.json`. The version output includes both runtime components: ```text -polymorfa/0.1.0-dev.0 sdk/0.1.0-dev.20261003052138 +polymorfa/0.1.0-dev.0 sdk/0.1.0-dev.20261004011426 ``` The SDK component reports the version built into the installed SDK. Dev @@ -2316,14 +2316,14 @@ Business Platform > Disconnect Account. An ineligible Number or pair fails with `hybrid_transition_ineligible` (exit code 4) and the API's reason in the message. Merging requires Hybrid Link access, which the API enforces. -These commands use published SDK `0.1.0-dev.20261003052138`, which pins API -contract `ee885ec4bf62af36b9b85df4310c505f38a224db`. +These commands use published SDK `0.1.0-dev.20261004011426`, which pins API +contract `171a9683ff45d11a4b5d3917dc428ad2722b0d99`. ### Hybrid Link controls These additions require a CLI release containing the Hybrid Link commands. -They use published SDK `0.1.0-dev.20261003052138`, which pins API contract -`ee885ec4bf62af36b9b85df4310c505f38a224db`. The SDK defaults native requests +They use published SDK `0.1.0-dev.20261004011426`, which pins API contract +`171a9683ff45d11a4b5d3917dc428ad2722b0d99`. The SDK defaults native requests to API revision `2026-09-22`. An explicit `--api-version` or saved-profile version remains explicit; retired versions fail without an automatic upgrade or resend. @@ -2480,7 +2480,7 @@ Project and client credentials cannot use these financial controls. Commands use the pinned SDK's authenticated raw transport; no unpublished SDK method is required. CLI publication and API deployment remain separate release gates. This source follows the financial-control contract at API commit -`ba09326dd3c6688f05320c1f7685aeb0290a0ed2`. +`171a9683ff45d11a4b5d3917dc428ad2722b0d99`. Read or update one resource without loading the team directory: @@ -2517,3 +2517,27 @@ polymorfa billing priority reorder --scope resource --project --i ``` Use either `--ids` or `--ids-file`. Funding order never bypasses a spending cap. + +### WhatsApp Analytics + +Read message and call outcomes, response times, number activity and connection +vitals with a credential carrying `sessions:read`. Enable Analytics in the +Console to collect business data. Project credentials stay within their project. + +```bash +polymorfa analytics get --project PROJECT_UUID --json +polymorfa analytics get --project PROJECT_UUID --session-id NUMBER_UUID --since 2026-10-01T00:00:00Z --until 2026-10-03T00:00:00Z --json +polymorfa analytics metrics --project PROJECT_UUID --window-hours 24 > analytics.prom +polymorfa analytics metrics --project PROJECT_UUID --segments --format openmetrics +``` + +JSON reads default to the preceding 30 days. Metrics cover 1–168 completed UTC +hours, defaulting to 24. They are overlapping-window gauges; do not apply +`rate()` or `increase()`. Read and reply rates use completed 24-hour conversation +windows. Null rates indicate no denominator or incomplete coverage. Phone +activity spans and quiet gaps describe observed signals, not device power state. +Disabled Analytics returns no business aggregates. + +These commands use the pinned SDK's `analytics.get` and `analytics.metrics` +methods and shared response types. A CLI release containing these commands and +an API deployment exposing Analytics are separate requirements. diff --git a/package-lock.json b/package-lock.json index a1a2b55..a0a78fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@modelcontextprotocol/server": "2.0.0", "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "0.1.0-dev.20261003052138", + "@polymorfa/sdk": "0.1.0-dev.20261004011426", "jq-wasm": "3.0.0-jq-1.8.2", "zod": "4.2.0" }, @@ -997,9 +997,9 @@ } }, "node_modules/@polymorfa/sdk": { - "version": "0.1.0-dev.20261003052138", - "resolved": "https://registry.npmjs.org/@polymorfa/sdk/-/sdk-0.1.0-dev.20261003052138.tgz", - "integrity": "sha512-NJLO6WcbDgCzmEiFiuQhSDdrF9cCuuVtKVH1CgmyH27yFQ/mFW62TK/p2lOHRVH5p45eyzX3r/7iCqSF2cOECA==", + "version": "0.1.0-dev.20261004011426", + "resolved": "https://registry.npmjs.org/@polymorfa/sdk/-/sdk-0.1.0-dev.20261004011426.tgz", + "integrity": "sha512-1bC03PyxMpzlRtIFBHfk6OQhcyqsgGKHfWrShq+0pN/0i+w0JRw3JoGo6UC1NdBKmsRIZtsRqs8cpYxsacbQFA==", "license": "MIT", "engines": { "node": ">=20" diff --git a/package.json b/package.json index 3fbf4ad..6283acc 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "@modelcontextprotocol/server": "2.0.0", "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "0.1.0-dev.20261003052138", + "@polymorfa/sdk": "0.1.0-dev.20261004011426", "jq-wasm": "3.0.0-jq-1.8.2", "zod": "4.2.0" }, diff --git a/src/services/analytics.ts b/src/services/analytics.ts index 05ea5b4..6d0c629 100644 --- a/src/services/analytics.ts +++ b/src/services/analytics.ts @@ -1,197 +1,14 @@ -// Platform analytics contract: polymorfa/polymorfa@e6006c2242ed8fe01abbe6b2d8db93c230508929 import { PolymorfaServerError, PolymorfaValidationError, type ClientBase, type ClientOwner, - type QueryValue, + type AnalyticsParams, + type AnalyticsMetricsParams, + type WhatsAppAnalytics, } from '@polymorfa/sdk' +export type {WhatsAppAnalytics} from '@polymorfa/sdk' import {optionalTimeRange} from '../input/time.js' -export interface CallOutcomeMetrics { - total: number - answered: number - missed: number - declined: number - failed: number - ringing: number - answerRate: number | null - talkSeconds: number - timedAnswered: number - timedPickup: number - averageTalkSeconds: number | null - medianTalkSeconds: number | null - p95TalkSeconds: number | null - averagePickupMs: number | null - p95PickupMs: number | null - shortAnswered: number - video: number -} -export interface CallBusinessMetrics extends CallOutcomeMetrics { - directions: {inbound: CallOutcomeMetrics; outbound: CallOutcomeMetrics} - mediaQuality: { - measuredCalls: number - averageJitterMs: number | null - averageRttMs: number | null - packetsLost: number | null - } - appQuality: { - measuredCalls: number - averageJitterMs: number | null - averageRttMs: number | null - packetLossRate: number | null - reconnects: number | null - } - endReasons: {code: string; count: number}[] - appErrors: {code: string; count: number}[] - transports: {code: string; count: number}[] - multiParticipantCalls: number - followUp: { - eligibleMissed: number - returnedWithin24h: number - rate: number | null - averageDelayMs: number | null - pendingWindow: number - unknownContact: number - } -} -export interface WhatsAppBusinessSegment { - dimension: - | 'message_type' - | 'text_band' - | 'origin' - | 'calling_code' - | 'customer_devices' - key: string - sendAttempts: number - sent: number - sendFailures: number - sendFailureRate: number | null - completedConversations: number - deliveredConversations: number - readConversations: number - repliedConversations: number - deliveryRate: number | null - readRate: number | null - replyRate: number | null - averageCustomerReplyMs: number | null - readRateInterval95: [number, number] | null - replyRateInterval95: [number, number] | null - readRateDifference: number | null - replyRateDifference: number | null - shareOfConversations: number | null - shareOfSends: number | null -} -export interface WhatsAppEngagement { - windowHours: 24 - completedConversations: number - deliveredConversations: number - readConversations: number - repliedConversations: number - deliveryRate: number | null - readRate: number | null - replyRate: number | null - averageCustomerReplyMs: number | null - complete: boolean - droppedConversations: number - droppedRecords: number - droppedReceiptJoins: number -} -export interface WhatsAppMetrics { - calls: CallBusinessMetrics - measured: boolean - observedHours: number - lastObservedAt: number | null - outgoingMessages: number - incomingMessages: number - sendAttempts: number - sendFailures: number - sendFailureRate: number | null - businessReplies: number - averageBusinessResponseMs: number | null - onlineMs: number - disconnects: number - connectFailures: number - streamErrors: number - keepaliveTimeouts: number - engagement: WhatsAppEngagement - messageAnalysis: {complete: boolean; segments: WhatsAppBusinessSegment[]} - customerActivity: { - observed: boolean - onlineSignals: number - offlineSignals: number - typingSignals: number - } - accountActivity: { - observed: boolean - primaryPhoneActivitySignals: number - primaryPhoneActivePeriods: number - completedPhoneActivityPeriods: number - phoneActivityMs: number - averagePhoneActivityMs: number | null - phoneQuietGaps: number - phoneQuietMs: number - averagePhoneQuietMs: number | null - primaryPhoneMessages: number - otherDeviceMessages: number - primaryPhoneReplies: number - otherDeviceReplies: number - averagePrimaryPhoneResponseMs: number | null - averageOtherDeviceResponseMs: number | null - lastPrimaryPhoneAt: number | null - } - responseQueue: { - awaitingReply: number - oldestWaitingMs: number - observedAt: number - complete: boolean - } | null -} -export interface WhatsAppAnalytics { - callSeries: (CallOutcomeMetrics & {sessionId: string; ts: number})[] - enabled: boolean - period: {start: number; end: number} - requestVitals: { - requests: number - failures: number - errorRate: number | null - } - summary: - | (WhatsAppMetrics & { - totalNumbers: number - measuredNumbers: number - connectedNumbers: number - }) - | null - numbers: (WhatsAppMetrics & { - sessionId: string - projectId: string - projectName: string - name: string - backend: string - status: string - })[] - series: { - customerOnlineSignals: number - customerTypingSignals: number - primaryPhoneMessages: number - otherDeviceMessages: number - primaryPhoneReplies: number - phoneActivePeriods: number - completedPhoneActivityPeriods: number - phoneActivityMs: number - phoneQuietGaps: number - phoneQuietMs: number - sessionId: string - ts: number - outgoingMessages: number - incomingMessages: number - sendFailures: number - businessReplies: number - averageBusinessResponseMs: number | null - onlineMs: number - disconnects: number - }[] -} export interface AnalyticsSettings { enabled: boolean } @@ -200,9 +17,9 @@ export function analyticsQuery(input: { since?: string until?: string sessionId?: string -}): Record { +}): AnalyticsParams { optionalTimeRange(input.since, input.until) - const query: Record = {} + const query: {start?: number; end?: number; sessionId?: string} = {} if (input.since !== undefined) query.start = Date.parse(input.since) if (input.until !== undefined) query.end = Date.parse(input.until) if ( @@ -228,15 +45,10 @@ export function analyticsQuery(input: { } export async function getAnalytics( client: ClientBase, - query: Record, + query: AnalyticsParams, ) { - // Project raw paths are confined by the SDK to /platform/projects/{id}. - const response = await client.raw.request<{data: WhatsAppAnalytics}>({ - method: 'GET', - path: client.projectId === null ? '/platform/analytics' : '/analytics', - query, - }) - const data = response.data?.data + const response = await client.analytics.get(query) + const data = response.data if ( !data || typeof data.enabled !== 'boolean' || @@ -301,7 +113,7 @@ export function analyticsMetricsQuery(input: { windowHours?: number | undefined segments?: boolean | undefined format?: string | undefined -}): Record { +}): AnalyticsMetricsParams { const query = analyticsQuery( input.sessionId === undefined ? {} : {sessionId: input.sessionId}, ) @@ -327,34 +139,9 @@ export function analyticsMetricsQuery(input: { format, } } -export async function getAnalyticsMetrics( +export function getAnalyticsMetrics( client: ClientBase, - query: Record, + query: AnalyticsMetricsParams, ) { - const response = await client.raw.request({ - method: 'GET', - path: - client.projectId === null - ? '/platform/analytics/metrics' - : '/analytics/metrics', - query, - }) - const expected = - query.format === 'openmetrics' - ? 'application/openmetrics-text' - : 'text/plain' - if ( - typeof response.data !== 'string' || - !/^# TYPE polymorfa_analytics_enabled gauge$/m.test(response.data) || - response.metadata.headers['content-type'] - ?.split(';', 1)[0] - ?.trim() - .toLowerCase() !== expected || - (query.format === 'openmetrics' && !response.data.endsWith('# EOF\n')) - ) - throw new PolymorfaServerError( - 'The API returned invalid analytics metrics.', - {code: 'invalid_response'}, - ) - return response + return client.analytics.metrics(query) }