diff --git a/.release-it.json b/.release-it.json index d5c21d9..9abdc11 100644 --- a/.release-it.json +++ b/.release-it.json @@ -22,7 +22,7 @@ { "type": "fix", "section": "Bug Fixes" }, { "type": "perf", "section": "Performance Improvements" }, { "type": "revert", "section": "Reverts" }, - { "type": "chore", "section": "Miscellaneous" }, + { "type": "chore", "section": "Miscellaneous", "hidden": true }, { "type": "docs", "section": "Documentation", "hidden": true }, { "type": "style", "section": "Styles", "hidden": true }, { "type": "refactor", "section": "Code Refactoring", "hidden": true }, diff --git a/ios/ApplePayModule.swift b/ios/ApplePayModule.swift index 1f3fd52..9e81ecd 100644 --- a/ios/ApplePayModule.swift +++ b/ios/ApplePayModule.swift @@ -13,6 +13,10 @@ class ApplePayModule: NSObject { private var paymentCompletion: ((PKPaymentAuthorizationResult) -> Void)? private var pendingResolve: ((Any) -> Void)? private var pendingReject: ((String, String, NSError) -> Void)? + /// At-most-once guard for the JS promise. Read and written exclusively on + /// the main queue — `requestPayment` hops to main before touching it, and + /// the PassKit delegate callbacks fire on main. + private var promiseSettled: Bool = false @objc static func moduleName() -> String { @@ -47,8 +51,6 @@ class ApplePayModule: NSObject { return } self.paymentCompletion = nil - self.pendingResolve = nil - self.pendingReject = nil let result = success ? PKPaymentAuthorizationResult(status: .success, errors: nil) @@ -65,23 +67,11 @@ class ApplePayModule: NSObject { func requestPayment(_ configJson: String, resolve: @escaping (Any) -> Void, reject: @escaping (String, String, NSError) -> Void) { - // Re-entry guard: rapid double-tap while an earlier sheet is still pending - // would otherwise overwrite pendingResolve and leak the first promise. - if self.pendingResolve != nil { - reject( - "IN_PROGRESS", - "Another Apple Pay authorization is already in progress", - NSError(domain: "BoltApplePay", code: 5) - ) - return - } - self.pendingResolve = resolve - self.pendingReject = reject - + // Parse config before hopping queues — pure data work, and an early + // rejection here avoids priming any module state that settleReject would + // then need to clean up. guard let configData = configJson.data(using: .utf8), let config = try? JSONSerialization.jsonObject(with: configData) as? [String: Any] else { - self.pendingResolve = nil - self.pendingReject = nil reject("INVALID_CONFIG", "Failed to parse Apple Pay config", NSError(domain: "BoltApplePay", code: 1)) return } @@ -103,10 +93,71 @@ class ApplePayModule: NSObject { request.requiredBillingContactFields = [.postalAddress, .name, .emailAddress, .phoneNumber] + // Hop to main for all module-state mutation and PassKit interaction. + // `pendingResolve`, `pendingReject`, and `promiseSettled` are only touched + // from main — keeping all writes (including the initial setup) here is + // what makes the promiseSettled guard thread-safe. DispatchQueue.main.async { + // Re-entry guard: rapid double-tap while an earlier sheet is still + // pending would otherwise overwrite pendingResolve and leak the first + // promise. + if self.pendingResolve != nil { + reject( + "IN_PROGRESS", + "Another Apple Pay authorization is already in progress", + NSError(domain: "BoltApplePay", code: 5) + ) + return + } + self.pendingResolve = resolve + self.pendingReject = reject + self.promiseSettled = false + let controller = PKPaymentAuthorizationController(paymentRequest: request) controller.delegate = self - controller.present() + controller.present { presented in + // If PassKit failed to present (invalid merchantId, missing + // entitlement, no supported networks, etc.) neither didAuthorize nor + // didFinish will fire, and the JS promise would hang forever without + // this explicit rejection. + if !presented { + self.settleReject( + "PRESENT_FAILED", + "Failed to present Apple Pay sheet", + NSError(domain: "BoltApplePay", code: 7) + ) + } + } + } + } + + /// Main-queue-serialized at-most-once promise settlement. Clears the + /// pending-handler fields so the requestPayment re-entry guard sees a fresh + /// state on the next call. + private func settleResolve(_ value: Any) { + DispatchQueue.main.async { + guard !self.promiseSettled else { return } + self.promiseSettled = true + self.pendingResolve?(value) + self.pendingResolve = nil + self.pendingReject = nil + } + } + + private func settleReject(_ code: String, _ message: String, _ error: NSError) { + DispatchQueue.main.async { + guard !self.promiseSettled else { return } + self.promiseSettled = true + if let pendingReject = self.pendingReject { + pendingReject(code, message, error) + } else { + // Programmer error: a settlement path ran without a pending handler. + // Flip the guard anyway so we don't loop, but log so the drop is + // diagnosable rather than silently swallowed. + NSLog("[BoltApplePay] settleReject with no pendingReject (code=%@): %@", code, message) + } + self.pendingResolve = nil + self.pendingReject = nil } } @@ -140,18 +191,16 @@ extension ApplePayModule: PKPaymentAuthorizationControllerDelegate { // IPostApplePayTokenRequest: { paymentData, paymentMethod, transactionIdentifier }. guard let paymentDataObject = try? JSONSerialization.jsonObject(with: payment.token.paymentData) as? [String: Any] else { - self.pendingReject?( + self.settleReject( "PAYMENT_DATA_PARSE_FAILED", "Failed to parse Apple Pay paymentData as JSON", NSError(domain: "BoltApplePay", code: 2) ) completion(PKPaymentAuthorizationResult(status: .failure, errors: nil)) - // PassKit forbids invoking the completion handler twice. Drop the - // retained state now so a later reportAuthorizationResult call from JS - // (or paymentAuthorizationControllerDidFinish) cannot re-invoke it. + // PassKit forbids invoking the completion handler twice; drop the + // retained completion so a later reportAuthorizationResult or didFinish + // can't re-invoke it. self.paymentCompletion = nil - self.pendingResolve = nil - self.pendingReject = nil return } @@ -191,18 +240,13 @@ extension ApplePayModule: PKPaymentAuthorizationControllerDelegate { // (which would hang the JS promise with no resolve and no reject). guard let jsonData = try? JSONSerialization.data(withJSONObject: response), let jsonString = String(data: jsonData, encoding: .utf8) else { - self.pendingReject?( + self.settleReject( "SERIALIZE_FAILED", "Failed to serialize Apple Pay result", NSError(domain: "BoltApplePay", code: 4) ) completion(PKPaymentAuthorizationResult(status: .failure, errors: nil)) - // Same invariant as the paymentData-parse branch above: drop the - // retained completion so a later reportAuthorizationResult or - // paymentAuthorizationControllerDidFinish cannot re-invoke it. self.paymentCompletion = nil - self.pendingResolve = nil - self.pendingReject = nil return } @@ -214,18 +258,22 @@ extension ApplePayModule: PKPaymentAuthorizationControllerDelegate { // invokes the retained completion. Calling completion here would flash the // sheet to ".success" before tokenization actually runs, showing the user // a successful checkmark even when tokenization fails. - self.pendingResolve?(jsonString) + self.settleResolve(jsonString) } func paymentAuthorizationControllerDidFinish(_ controller: PKPaymentAuthorizationController) { - // The sheet is closing. Drop any retained state so the next requestPayment - // starts clean — if JS never called reportAuthorizationResult the stale - // completion handler would stay attached and confuse the re-entry guard. self.paymentCompletion = nil - self.pendingResolve = nil - self.pendingReject = nil DispatchQueue.main.async { - controller.dismiss() + // Fire-and-forget dismiss: Apple does not reliably invoke the dismiss + // completion in every path (see FB7478242-class reports), so we settle + // the JS promise outside the completion block. If didAuthorize already + // settled the promise, the promiseSettled guard makes this a no-op. + controller.dismiss(completion: nil) + self.settleReject( + "CANCELLED", + "User cancelled Apple Pay", + NSError(domain: "BoltApplePay", code: 6) + ) } } } diff --git a/src/__tests__/ApplePay.test.tsx b/src/__tests__/ApplePay.test.tsx index 55f747d..a652ba2 100644 --- a/src/__tests__/ApplePay.test.tsx +++ b/src/__tests__/ApplePay.test.tsx @@ -58,12 +58,22 @@ jest.mock('../client/useTkClient', () => ({ const mockSpan = { setStatus: jest.fn(), recordException: jest.fn(), + addEvent: jest.fn(), end: jest.fn(), }; +const mockStartSpan = jest.fn< + typeof mockSpan, + [string, Record?] +>(() => mockSpan); +const mockRecordEvent = jest.fn?]>(); + jest.mock('../telemetry/tracer', () => ({ - startSpan: () => mockSpan, - SpanStatusCode: { OK: 1, ERROR: 2 }, + startSpan: (name: string, attrs?: Record) => + mockStartSpan(name, attrs), + recordEvent: (name: string, attrs?: Record) => + mockRecordEvent(name, attrs), + SpanStatusCode: { OK: 1, ERROR: 2, UNSET: 0 }, })); const baseConfig: ApplePayConfig = { @@ -199,20 +209,77 @@ describe('ApplePay (native mode)', () => { ); }); - it('propagates rejections from requestPayment without calling report', async () => { - mockRequestPayment.mockRejectedValue(new Error('User cancelled')); + it('propagates non-CANCELLED native rejections through onError', async () => { + // A rejection from the native module that doesn't carry `code: 'CANCELLED'` + // (bridge contract failure, PRESENT_FAILED, etc.) — callers should see it. + mockRequestPayment.mockRejectedValue(new Error('Native bridge error')); const { button, onComplete, onError } = await renderNative(); fireEvent(button, 'press'); await waitFor(() => expect(onError).toHaveBeenCalledTimes(1)); - expect(onError.mock.calls[0][0].message).toBe('User cancelled'); + expect(onError.mock.calls[0][0].message).toBe('Native bridge error'); expect(onComplete).not.toHaveBeenCalled(); // Authorization was never reached — sheet is already dismissed by PassKit, // so there's nothing to report back to native. expect(mockReportAuthorizationResult).not.toHaveBeenCalled(); }); + it('treats native CANCELLED code as a silent cancel (no onError)', async () => { + // User dismissing the sheet: native rejects with code 'CANCELLED'. This + // must not surface to onError (which would flash a "cancelled" error to + // merchant-level error handlers) and must not trigger tokenization or + // a PassKit result report — the sheet is already gone. + const cancelErr = Object.assign(new Error('User cancelled Apple Pay'), { + code: 'CANCELLED', + }); + mockRequestPayment.mockRejectedValue(cancelErr); + + const { button, onComplete, onError } = await renderNative(); + fireEvent(button, 'press'); + + await waitFor(() => + expect(mockSpan.addEvent).toHaveBeenCalledWith( + 'bolt.apple_pay.cancelled', + expect.objectContaining({ 'payment.cancelled': true }) + ) + ); + expect(onError).not.toHaveBeenCalled(); + expect(onComplete).not.toHaveBeenCalled(); + expect(mockPostApplePayToken).not.toHaveBeenCalled(); + expect(mockReportAuthorizationResult).not.toHaveBeenCalled(); + }); + + it('records a button_pressed event and a request_payment parent span', async () => { + mockRequestPayment.mockResolvedValue(JSON.stringify(rawNativeResponse)); + mockPostApplePayToken.mockResolvedValue({ + token: 'bolt_tok', + bin: '411111', + expiry: '2027-12', + last4: '1234', + network: 'visa', + }); + + const { button } = await renderNative(); + fireEvent(button, 'press'); + + await waitFor(() => + expect(mockRecordEvent).toHaveBeenCalledWith( + 'bolt.apple_pay.button_pressed', + expect.any(Object) + ) + ); + expect(mockStartSpan).toHaveBeenCalledWith( + 'bolt.apple_pay.request_payment', + expect.any(Object) + ); + await waitFor(() => + expect(mockSpan.addEvent).toHaveBeenCalledWith( + 'bolt.apple_pay.tokenize_success' + ) + ); + }); + describe('buttonType type-level validity', () => { it('should accept all valid ApplePayButtonType values', () => { const validTypes: ApplePayButtonType[] = [ diff --git a/src/__tests__/GoogleWallet.test.tsx b/src/__tests__/GoogleWallet.test.tsx index 6338086..9f89341 100644 --- a/src/__tests__/GoogleWallet.test.tsx +++ b/src/__tests__/GoogleWallet.test.tsx @@ -54,12 +54,22 @@ jest.mock('../client/useTkClient', () => ({ const mockSpan = { setStatus: jest.fn(), recordException: jest.fn(), + addEvent: jest.fn(), end: jest.fn(), }; +const mockStartSpan = jest.fn< + typeof mockSpan, + [string, Record?] +>(() => mockSpan); +const mockRecordEvent = jest.fn?]>(); + jest.mock('../telemetry/tracer', () => ({ - startSpan: () => mockSpan, - SpanStatusCode: { OK: 1, ERROR: 2 }, + startSpan: (name: string, attrs?: Record) => + mockStartSpan(name, attrs), + recordEvent: (name: string, attrs?: Record) => + mockRecordEvent(name, attrs), + SpanStatusCode: { OK: 1, ERROR: 2, UNSET: 0 }, })); // Require the Android-platform source explicitly. Jest's haste is configured @@ -209,17 +219,71 @@ describe('GoogleWallet', () => { expect(onError.mock.calls[0][0].message).toMatch(/missing googlePayToken/); }); - it('propagates rejections from requestPayment', async () => { - mockRequestPayment.mockRejectedValue(new Error('User cancelled')); + it('propagates non-CANCELLED native rejections through onError', async () => { + // A rejection without `code: 'CANCELLED'` — generic native failure. + mockRequestPayment.mockRejectedValue(new Error('Native bridge error')); const { button, onComplete, onError } = await renderWallet(); fireEvent(button, 'press'); await waitFor(() => expect(onError).toHaveBeenCalledTimes(1)); - expect(onError.mock.calls[0][0].message).toBe('User cancelled'); + expect(onError.mock.calls[0][0].message).toBe('Native bridge error'); expect(onComplete).not.toHaveBeenCalled(); }); + it('treats native CANCELLED code as a silent cancel (no onError)', async () => { + // GooglePayModule.kt rejects with code 'CANCELLED' on user dismissal. + // Consumers should not see this as an error. + const cancelErr = Object.assign( + new Error('Google Pay was cancelled or failed'), + { code: 'CANCELLED' } + ); + mockRequestPayment.mockRejectedValue(cancelErr); + + const { button, onComplete, onError } = await renderWallet(); + fireEvent(button, 'press'); + + await waitFor(() => + expect(mockSpan.addEvent).toHaveBeenCalledWith( + 'bolt.google_pay.cancelled', + expect.objectContaining({ 'payment.cancelled': true }) + ) + ); + expect(onError).not.toHaveBeenCalled(); + expect(onComplete).not.toHaveBeenCalled(); + expect(mockPostGooglePayToken).not.toHaveBeenCalled(); + }); + + it('records a button_pressed event and a request_payment parent span', async () => { + mockRequestPayment.mockResolvedValue(JSON.stringify(rawNativeResponse)); + mockPostGooglePayToken.mockResolvedValue({ + token: 'bolt_tok', + bin: '411111', + expiry: '2027-12', + last4: '1234', + network: 'visa', + }); + + const { button } = await renderWallet(); + fireEvent(button, 'press'); + + await waitFor(() => + expect(mockRecordEvent).toHaveBeenCalledWith( + 'bolt.google_pay.button_pressed', + expect.any(Object) + ) + ); + expect(mockStartSpan).toHaveBeenCalledWith( + 'bolt.google_pay.request_payment', + expect.any(Object) + ); + await waitFor(() => + expect(mockSpan.addEvent).toHaveBeenCalledWith( + 'bolt.google_pay.tokenize_success' + ) + ); + }); + it('does not render the button when isReadyToPay resolves false', async () => { mockIsReadyToPay.mockResolvedValue(false); diff --git a/src/payments/ApplePay.tsx b/src/payments/ApplePay.tsx index 3f5c382..ef3ee15 100644 --- a/src/payments/ApplePay.tsx +++ b/src/payments/ApplePay.tsx @@ -9,7 +9,7 @@ import type { ApplePayButtonType, ApplePayBillingContact, } from './types'; -import { startSpan, SpanStatusCode } from '../telemetry/tracer'; +import { recordEvent, startSpan, SpanStatusCode } from '../telemetry/tracer'; import { BoltAttributes } from '../telemetry/attributes'; import { logger } from '../telemetry/logger'; import { ApplePayWebView } from './ApplePayWebView'; @@ -131,6 +131,11 @@ const ApplePayNative = ({ return; } + recordEvent('bolt.apple_pay.button_pressed', { + [BoltAttributes.PAYMENT_METHOD]: 'apple_pay', + [BoltAttributes.PAYMENT_OPERATION]: 'button_pressed', + }); + const span = startSpan('bolt.apple_pay.request_payment', { [BoltAttributes.PAYMENT_METHOD]: 'apple_pay', [BoltAttributes.PAYMENT_OPERATION]: 'request_payment', @@ -171,11 +176,30 @@ const ApplePayNative = ({ billingContact: raw.billingContact, }; success = true; + + span.addEvent('bolt.apple_pay.tokenize_success'); span.setStatus({ code: SpanStatusCode.OK }); span.end(); } catch (err) { lastError = err instanceof Error ? err : new Error('Apple Pay payment failed'); + // The native module rejects with `code: 'CANCELLED'` on user dismissal. + // Match the WebView mode's behavior and treat cancel as a silent, + // consumer-invisible outcome — don't route through onError, which would + // surface a "User cancelled" error to merchant-level error handlers. + const nativeCode = (err as { code?: string }).code; + if (nativeCode === 'CANCELLED') { + span.addEvent('bolt.apple_pay.cancelled', { + [BoltAttributes.PAYMENT_CANCELLED]: true, + }); + span.setStatus({ code: SpanStatusCode.UNSET }); + span.end(); + return; + } + + span.addEvent('bolt.apple_pay.tokenize_failure', { + [BoltAttributes.ERROR_MESSAGE]: lastError.message, + }); span.setStatus({ code: SpanStatusCode.ERROR, message: lastError.message, diff --git a/src/payments/ApplePayWebView.tsx b/src/payments/ApplePayWebView.tsx index 647df25..7c6658b 100644 --- a/src/payments/ApplePayWebView.tsx +++ b/src/payments/ApplePayWebView.tsx @@ -7,7 +7,7 @@ import NativeApplePay from '../native/NativeApplePay'; import { BoltBridgeDispatcher } from '../bridge/BoltBridgeDispatcher'; import { INJECTED_BRIDGE_JS } from '../bridge/injectedBridge'; import { parseBoltMessage } from '../bridge/parseBoltMessage'; -import { startSpan, SpanStatusCode } from '../telemetry/tracer'; +import { recordEvent, startSpan, SpanStatusCode } from '../telemetry/tracer'; import { BoltAttributes } from '../telemetry/attributes'; import { logger } from '../telemetry/logger'; import type { @@ -197,6 +197,11 @@ export const ApplePayWebView = ({ if (errorCode === ERROR_CANCELLED) { // User dismissed the Apple Pay sheet. Not a caller-facing error; // the iframe resets the button state on its own. + recordEvent('bolt.apple_pay.webview_cancelled', { + [BoltAttributes.PAYMENT_METHOD]: 'apple_pay', + [BoltAttributes.PAYMENT_OPERATION]: 'request_payment', + [BoltAttributes.PAYMENT_CANCELLED]: true, + }); return; } diff --git a/src/payments/GoogleWallet.tsx b/src/payments/GoogleWallet.tsx index 1b06676..61ba058 100644 --- a/src/payments/GoogleWallet.tsx +++ b/src/payments/GoogleWallet.tsx @@ -13,7 +13,7 @@ import type { GooglePayAPMConfig, GooglePayBillingAddress, } from './types'; -import { startSpan, SpanStatusCode } from '../telemetry/tracer'; +import { recordEvent, startSpan, SpanStatusCode } from '../telemetry/tracer'; import { BoltAttributes } from '../telemetry/attributes'; import { logger } from '../telemetry/logger'; import { fetchGooglePayAPMConfig } from './googlePayApi'; @@ -100,6 +100,11 @@ export const GoogleWallet = ({ return; } + recordEvent('bolt.google_pay.button_pressed', { + [BoltAttributes.PAYMENT_METHOD]: 'google_pay', + [BoltAttributes.PAYMENT_OPERATION]: 'button_pressed', + }); + const span = startSpan('bolt.google_pay.request_payment', { [BoltAttributes.PAYMENT_METHOD]: 'google_pay', [BoltAttributes.PAYMENT_OPERATION]: 'request_payment', @@ -139,11 +144,31 @@ export const GoogleWallet = ({ email: raw.email, billingAddress: raw.billingAddress, }; + + span.addEvent('bolt.google_pay.tokenize_success'); span.setStatus({ code: SpanStatusCode.OK }); span.end(); } catch (err) { const error = err instanceof Error ? err : new Error('Google Pay payment failed'); + // GooglePayModule.kt rejects with `code: 'CANCELLED'` on user dismissal + // (and unfortunately also generic Google Pay errors; the native side + // conflates them). Mirror the Apple Pay and WebView handling and treat + // this code path as a silent cancel so merchants don't see a phantom + // error for every dismissal. + const nativeCode = (err as { code?: string }).code; + if (nativeCode === 'CANCELLED') { + span.addEvent('bolt.google_pay.cancelled', { + [BoltAttributes.PAYMENT_CANCELLED]: true, + }); + span.setStatus({ code: SpanStatusCode.UNSET }); + span.end(); + return; + } + + span.addEvent('bolt.google_pay.tokenize_failure', { + [BoltAttributes.ERROR_MESSAGE]: error.message, + }); span.setStatus({ code: SpanStatusCode.ERROR, message: error.message }); span.recordException(error); span.end(); diff --git a/src/telemetry/attributes.ts b/src/telemetry/attributes.ts index bd59f67..bd49b12 100644 --- a/src/telemetry/attributes.ts +++ b/src/telemetry/attributes.ts @@ -3,8 +3,10 @@ export const INSTRUMENTATION_NAME = '@boltpay/react-native'; export const BoltAttributes = { ENVIRONMENT: 'bolt.environment', PUBLISHABLE_KEY: 'bolt.publishable_key', + PLATFORM: 'bolt.platform', PAYMENT_METHOD: 'payment.method', PAYMENT_OPERATION: 'payment.operation', + PAYMENT_CANCELLED: 'payment.cancelled', BRIDGE_MESSAGE_TYPE: 'bolt.bridge.message_type', BRIDGE_DIRECTION: 'bolt.bridge.direction', ERROR_TYPE: 'error.type', diff --git a/src/telemetry/setup.ts b/src/telemetry/setup.ts index 49a5c89..6ce6684 100644 --- a/src/telemetry/setup.ts +++ b/src/telemetry/setup.ts @@ -76,7 +76,9 @@ export const initTelemetry = (config: BoltConfig): void => { [ATTR_SERVICE_NAME]: INSTRUMENTATION_NAME, [ATTR_SERVICE_VERSION]: SDK_VERSION, [BoltAttributes.ENVIRONMENT]: config.environment ?? 'production', - [BoltAttributes.PUBLISHABLE_KEY]: config.publishableKey.slice(0, 8) + '...', + [BoltAttributes.PUBLISHABLE_KEY]: + config.publishableKey.slice(0, 12) + '...', + [BoltAttributes.PLATFORM]: 'react-native', [ATTR_OS_NAME]: Platform.OS, [ATTR_OS_VERSION]: String(Platform.Version), }); diff --git a/src/telemetry/tracer.ts b/src/telemetry/tracer.ts index 9c5f3e9..4934657 100644 --- a/src/telemetry/tracer.ts +++ b/src/telemetry/tracer.ts @@ -7,3 +7,15 @@ export type { Span }; export const startSpan = (name: string, attributes?: Attributes): Span => trace.getTracer(INSTRUMENTATION_NAME).startSpan(name, { attributes }); + +/** + * Emit a zero-duration funnel marker as a standalone span. Use this for + * point-in-time events that don't have a parent span in scope (e.g. a button + * press that precedes the operation span). For markers inside an existing + * operation span, prefer `parentSpan.addEvent(...)` to preserve correlation. + */ +export const recordEvent = (name: string, attributes?: Attributes): void => { + const span = startSpan(name, attributes); + span.setStatus({ code: SpanStatusCode.OK }); + span.end(); +};