From f9a6f975565e9e07d84fb48dabe1183ab58d392d Mon Sep 17 00:00:00 2001 From: Aryeh Stiefel Date: Thu, 16 Apr 2026 14:24:01 -0400 Subject: [PATCH 1/2] chore(telemetry): add payment funnel spans for Apple Pay and Google Wallet --- .release-it.json | 2 +- ios/ApplePayModule.swift | 74 ++++++++++++++++++++++---------- src/payments/ApplePay.tsx | 41 ++++++++++++++++++ src/payments/ApplePayWebView.tsx | 7 +++ src/payments/GoogleWallet.tsx | 27 ++++++++++++ src/telemetry/attributes.ts | 2 + src/telemetry/setup.ts | 4 +- 7 files changed, 132 insertions(+), 25 deletions(-) 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..4395223 100644 --- a/ios/ApplePayModule.swift +++ b/ios/ApplePayModule.swift @@ -13,6 +13,12 @@ class ApplePayModule: NSObject { private var paymentCompletion: ((PKPaymentAuthorizationResult) -> Void)? private var pendingResolve: ((Any) -> Void)? private var pendingReject: ((String, String, NSError) -> Void)? + /// Tracks whether the promise has been settled (resolved or rejected) so + /// `paymentAuthorizationControllerDidFinish` can detect user cancellation. + /// All promise settlements are dispatched to the main queue so that + /// `promiseSettled` is only ever read/written from one thread, avoiding + /// races between the RN bridge method queue and PassKit delegate calls. + private var promiseSettled: Bool = false @objc static func moduleName() -> String { @@ -47,8 +53,6 @@ class ApplePayModule: NSObject { return } self.paymentCompletion = nil - self.pendingResolve = nil - self.pendingReject = nil let result = success ? PKPaymentAuthorizationResult(status: .success, errors: nil) @@ -77,12 +81,11 @@ class ApplePayModule: NSObject { } self.pendingResolve = resolve self.pendingReject = reject + self.promiseSettled = false 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)) + settleReject("INVALID_CONFIG", "Failed to parse Apple Pay config", NSError(domain: "BoltApplePay", code: 1)) return } @@ -110,6 +113,32 @@ class ApplePayModule: NSObject { } } + /// Settle the pending JS promise with a resolved value. Dispatched to main so + /// it can't race with PassKit delegate callbacks or the RN bridge method + /// queue. `promiseSettled` ensures at-most-once delivery and also lets + /// `paymentAuthorizationControllerDidFinish` distinguish cancel vs. completion. + /// Clears the pending-handler fields so the re-entry guard in requestPayment + /// resets for 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 + self.pendingReject?(code, message, error) + self.pendingResolve = nil + self.pendingReject = nil + } + } + /// Map PKPaymentMethodType to the semantic name string the tokenizer expects. /// rawValue is UInt (0..5) which is not meaningful to downstream systems. private static func paymentMethodTypeName(_ type: PKPaymentMethodType) -> String { @@ -140,18 +169,17 @@ 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. + // retained completion now so a later reportAuthorizationResult call + // from JS (or paymentAuthorizationControllerDidFinish) cannot re-invoke + // it. settleReject handles clearing pendingResolve/pendingReject. self.paymentCompletion = nil - self.pendingResolve = nil - self.pendingReject = nil return } @@ -191,18 +219,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 +237,23 @@ 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() + controller.dismiss { + // If the promise was never settled, the user dismissed the sheet + // before authorizing — this is a cancellation. When the promise has + // already been settled (success or earlier error), settleReject is a + // no-op thanks to the promiseSettled guard. + self.settleReject( + "CANCELLED", + "User cancelled Apple Pay", + NSError(domain: "BoltApplePay", code: 6) + ) + } } } } diff --git a/src/payments/ApplePay.tsx b/src/payments/ApplePay.tsx index 3f5c382..e69acc0 100644 --- a/src/payments/ApplePay.tsx +++ b/src/payments/ApplePay.tsx @@ -131,6 +131,13 @@ const ApplePayNative = ({ return; } + const buttonSpan = startSpan('bolt.apple_pay.button_pressed', { + [BoltAttributes.PAYMENT_METHOD]: 'apple_pay', + [BoltAttributes.PAYMENT_OPERATION]: 'button_pressed', + }); + buttonSpan.setStatus({ code: SpanStatusCode.OK }); + buttonSpan.end(); + const span = startSpan('bolt.apple_pay.request_payment', { [BoltAttributes.PAYMENT_METHOD]: 'apple_pay', [BoltAttributes.PAYMENT_OPERATION]: 'request_payment', @@ -171,11 +178,45 @@ const ApplePayNative = ({ billingContact: raw.billingContact, }; success = true; + + const tokenizeSpan = startSpan('bolt.apple_pay.tokenize_success', { + [BoltAttributes.PAYMENT_METHOD]: 'apple_pay', + [BoltAttributes.PAYMENT_OPERATION]: 'tokenize', + }); + tokenizeSpan.setStatus({ code: SpanStatusCode.OK }); + tokenizeSpan.end(); + span.setStatus({ code: SpanStatusCode.OK }); span.end(); } catch (err) { lastError = err instanceof Error ? err : new Error('Apple Pay payment failed'); + const nativeCode = (err as { code?: string }).code; + + if (nativeCode === 'CANCELLED') { + const cancelSpan = startSpan('bolt.apple_pay.cancelled', { + [BoltAttributes.PAYMENT_METHOD]: 'apple_pay', + [BoltAttributes.PAYMENT_CANCELLED]: true, + }); + cancelSpan.setStatus({ code: SpanStatusCode.OK }); + cancelSpan.end(); + span.setStatus({ code: SpanStatusCode.OK, message: 'user_cancelled' }); + span.end(); + onError?.(lastError); + return; + } + + const tokenizeSpan = startSpan('bolt.apple_pay.tokenize_failure', { + [BoltAttributes.PAYMENT_METHOD]: 'apple_pay', + [BoltAttributes.PAYMENT_OPERATION]: 'tokenize', + [BoltAttributes.ERROR_MESSAGE]: lastError.message, + }); + tokenizeSpan.setStatus({ + code: SpanStatusCode.ERROR, + message: lastError.message, + }); + tokenizeSpan.end(); + span.setStatus({ code: SpanStatusCode.ERROR, message: lastError.message, diff --git a/src/payments/ApplePayWebView.tsx b/src/payments/ApplePayWebView.tsx index 647df25..8e53ad8 100644 --- a/src/payments/ApplePayWebView.tsx +++ b/src/payments/ApplePayWebView.tsx @@ -197,6 +197,13 @@ 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. + const cancelSpan = startSpan('bolt.apple_pay.webview_cancelled', { + [BoltAttributes.PAYMENT_METHOD]: 'apple_pay', + [BoltAttributes.PAYMENT_OPERATION]: 'request_payment', + [BoltAttributes.PAYMENT_CANCELLED]: true, + }); + cancelSpan.setStatus({ code: SpanStatusCode.OK }); + cancelSpan.end(); return; } diff --git a/src/payments/GoogleWallet.tsx b/src/payments/GoogleWallet.tsx index 1b06676..6682216 100644 --- a/src/payments/GoogleWallet.tsx +++ b/src/payments/GoogleWallet.tsx @@ -100,6 +100,13 @@ export const GoogleWallet = ({ return; } + const buttonSpan = startSpan('bolt.google_pay.button_pressed', { + [BoltAttributes.PAYMENT_METHOD]: 'google_pay', + [BoltAttributes.PAYMENT_OPERATION]: 'button_pressed', + }); + buttonSpan.setStatus({ code: SpanStatusCode.OK }); + buttonSpan.end(); + const span = startSpan('bolt.google_pay.request_payment', { [BoltAttributes.PAYMENT_METHOD]: 'google_pay', [BoltAttributes.PAYMENT_OPERATION]: 'request_payment', @@ -139,11 +146,31 @@ export const GoogleWallet = ({ email: raw.email, billingAddress: raw.billingAddress, }; + + const tokenizeSpan = startSpan('bolt.google_pay.tokenize_success', { + [BoltAttributes.PAYMENT_METHOD]: 'google_pay', + [BoltAttributes.PAYMENT_OPERATION]: 'tokenize', + }); + tokenizeSpan.setStatus({ code: SpanStatusCode.OK }); + tokenizeSpan.end(); + span.setStatus({ code: SpanStatusCode.OK }); span.end(); } catch (err) { const error = err instanceof Error ? err : new Error('Google Pay payment failed'); + + const tokenizeSpan = startSpan('bolt.google_pay.tokenize_failure', { + [BoltAttributes.PAYMENT_METHOD]: 'google_pay', + [BoltAttributes.PAYMENT_OPERATION]: 'tokenize', + [BoltAttributes.ERROR_MESSAGE]: error.message, + }); + tokenizeSpan.setStatus({ + code: SpanStatusCode.ERROR, + message: error.message, + }); + tokenizeSpan.end(); + 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), }); From ed82163112a633e5ea324698030c2d235edca06b Mon Sep 17 00:00:00 2001 From: Aryeh Stiefel Date: Thu, 23 Apr 2026 16:37:36 -0400 Subject: [PATCH 2/2] fix(apple-pay): address review findings on telemetry PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iOS ApplePayModule: - Move pendingResolve/pendingReject/promiseSettled writes onto the main queue so the single-thread invariant the docstring promises actually holds. Previously the initial reset ran on the RN bridge queue, creating a race with main-queue PassKit delegate callbacks. - Use present(completion:) and reject with PRESENT_FAILED when PassKit can't present the sheet (invalid merchantId, missing entitlement, etc.) — otherwise neither didAuthorize nor didFinish ever fires and the JS promise hangs forever. - Move the synthesized CANCELLED settleReject outside the dismiss completion block. Apple's dismiss completion is not reliably invoked in every path, so keeping the settlement inside it is a leak risk. - Log when settleReject runs with no pendingReject instead of silently flipping the promiseSettled guard. - Trim redundant comments. JS: - Cancellation is now silent across all three payment surfaces (Apple Pay native, Apple Pay WebView, Google Pay) — no onError call, to match the WebView's pre-existing behavior and avoid surfacing "User cancelled" to merchant error handlers. - Collapse zero-duration tokenize_success / tokenize_failure / cancelled child spans into parentSpan.addEvent(...) on the request_payment span so funnel markers correlate with the parent. - Add a shared recordEvent() helper in tracer.ts for standalone funnel markers (button_pressed) that precede the parent span. Tests: - Upgrade the startSpan mock to a jest.fn that captures span names so tests can differentiate the spans being emitted. - Add coverage for the native CANCELLED-code path on both Apple Pay and Google Wallet — the prior test used Error('User cancelled') without a .code property, which hit the fall-through tokenize_ failure path rather than the new CANCELLED branch. - Add coverage asserting button_pressed event and tokenize_success span-event emission. Co-Authored-By: Claude Opus 4.7 (1M context) --- ios/ApplePayModule.swift | 106 +++++++++++++++++----------- src/__tests__/ApplePay.test.tsx | 77 ++++++++++++++++++-- src/__tests__/GoogleWallet.test.tsx | 74 +++++++++++++++++-- src/payments/ApplePay.tsx | 37 +++------- src/payments/ApplePayWebView.tsx | 6 +- src/payments/GoogleWallet.tsx | 38 +++++----- src/telemetry/tracer.ts | 12 ++++ 7 files changed, 246 insertions(+), 104 deletions(-) diff --git a/ios/ApplePayModule.swift b/ios/ApplePayModule.swift index 4395223..9e81ecd 100644 --- a/ios/ApplePayModule.swift +++ b/ios/ApplePayModule.swift @@ -13,11 +13,9 @@ class ApplePayModule: NSObject { private var paymentCompletion: ((PKPaymentAuthorizationResult) -> Void)? private var pendingResolve: ((Any) -> Void)? private var pendingReject: ((String, String, NSError) -> Void)? - /// Tracks whether the promise has been settled (resolved or rejected) so - /// `paymentAuthorizationControllerDidFinish` can detect user cancellation. - /// All promise settlements are dispatched to the main queue so that - /// `promiseSettled` is only ever read/written from one thread, avoiding - /// races between the RN bridge method queue and PassKit delegate calls. + /// 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 @@ -69,23 +67,12 @@ 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 - self.promiseSettled = false - + // 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 { - settleReject("INVALID_CONFIG", "Failed to parse Apple Pay config", NSError(domain: "BoltApplePay", code: 1)) + reject("INVALID_CONFIG", "Failed to parse Apple Pay config", NSError(domain: "BoltApplePay", code: 1)) return } @@ -106,19 +93,47 @@ 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) + ) + } + } } } - /// Settle the pending JS promise with a resolved value. Dispatched to main so - /// it can't race with PassKit delegate callbacks or the RN bridge method - /// queue. `promiseSettled` ensures at-most-once delivery and also lets - /// `paymentAuthorizationControllerDidFinish` distinguish cancel vs. completion. - /// Clears the pending-handler fields so the re-entry guard in requestPayment - /// resets for the next call. + /// 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 } @@ -133,7 +148,14 @@ class ApplePayModule: NSObject { DispatchQueue.main.async { guard !self.promiseSettled else { return } self.promiseSettled = true - self.pendingReject?(code, message, error) + 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 } @@ -175,10 +197,9 @@ extension ApplePayModule: PKPaymentAuthorizationControllerDelegate { NSError(domain: "BoltApplePay", code: 2) ) completion(PKPaymentAuthorizationResult(status: .failure, errors: nil)) - // PassKit forbids invoking the completion handler twice. Drop the - // retained completion now so a later reportAuthorizationResult call - // from JS (or paymentAuthorizationControllerDidFinish) cannot re-invoke - // it. settleReject handles clearing pendingResolve/pendingReject. + // 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 return } @@ -243,17 +264,16 @@ extension ApplePayModule: PKPaymentAuthorizationControllerDelegate { func paymentAuthorizationControllerDidFinish(_ controller: PKPaymentAuthorizationController) { self.paymentCompletion = nil DispatchQueue.main.async { - controller.dismiss { - // If the promise was never settled, the user dismissed the sheet - // before authorizing — this is a cancellation. When the promise has - // already been settled (success or earlier error), settleReject is a - // no-op thanks to the promiseSettled guard. - self.settleReject( - "CANCELLED", - "User cancelled Apple Pay", - NSError(domain: "BoltApplePay", code: 6) - ) - } + // 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 e69acc0..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,12 +131,10 @@ const ApplePayNative = ({ return; } - const buttonSpan = startSpan('bolt.apple_pay.button_pressed', { + recordEvent('bolt.apple_pay.button_pressed', { [BoltAttributes.PAYMENT_METHOD]: 'apple_pay', [BoltAttributes.PAYMENT_OPERATION]: 'button_pressed', }); - buttonSpan.setStatus({ code: SpanStatusCode.OK }); - buttonSpan.end(); const span = startSpan('bolt.apple_pay.request_payment', { [BoltAttributes.PAYMENT_METHOD]: 'apple_pay', @@ -179,44 +177,29 @@ const ApplePayNative = ({ }; success = true; - const tokenizeSpan = startSpan('bolt.apple_pay.tokenize_success', { - [BoltAttributes.PAYMENT_METHOD]: 'apple_pay', - [BoltAttributes.PAYMENT_OPERATION]: 'tokenize', - }); - tokenizeSpan.setStatus({ code: SpanStatusCode.OK }); - tokenizeSpan.end(); - + 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') { - const cancelSpan = startSpan('bolt.apple_pay.cancelled', { - [BoltAttributes.PAYMENT_METHOD]: 'apple_pay', + span.addEvent('bolt.apple_pay.cancelled', { [BoltAttributes.PAYMENT_CANCELLED]: true, }); - cancelSpan.setStatus({ code: SpanStatusCode.OK }); - cancelSpan.end(); - span.setStatus({ code: SpanStatusCode.OK, message: 'user_cancelled' }); + span.setStatus({ code: SpanStatusCode.UNSET }); span.end(); - onError?.(lastError); return; } - const tokenizeSpan = startSpan('bolt.apple_pay.tokenize_failure', { - [BoltAttributes.PAYMENT_METHOD]: 'apple_pay', - [BoltAttributes.PAYMENT_OPERATION]: 'tokenize', + span.addEvent('bolt.apple_pay.tokenize_failure', { [BoltAttributes.ERROR_MESSAGE]: lastError.message, }); - tokenizeSpan.setStatus({ - code: SpanStatusCode.ERROR, - message: lastError.message, - }); - tokenizeSpan.end(); - span.setStatus({ code: SpanStatusCode.ERROR, message: lastError.message, diff --git a/src/payments/ApplePayWebView.tsx b/src/payments/ApplePayWebView.tsx index 8e53ad8..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,13 +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. - const cancelSpan = startSpan('bolt.apple_pay.webview_cancelled', { + recordEvent('bolt.apple_pay.webview_cancelled', { [BoltAttributes.PAYMENT_METHOD]: 'apple_pay', [BoltAttributes.PAYMENT_OPERATION]: 'request_payment', [BoltAttributes.PAYMENT_CANCELLED]: true, }); - cancelSpan.setStatus({ code: SpanStatusCode.OK }); - cancelSpan.end(); return; } diff --git a/src/payments/GoogleWallet.tsx b/src/payments/GoogleWallet.tsx index 6682216..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,12 +100,10 @@ export const GoogleWallet = ({ return; } - const buttonSpan = startSpan('bolt.google_pay.button_pressed', { + recordEvent('bolt.google_pay.button_pressed', { [BoltAttributes.PAYMENT_METHOD]: 'google_pay', [BoltAttributes.PAYMENT_OPERATION]: 'button_pressed', }); - buttonSpan.setStatus({ code: SpanStatusCode.OK }); - buttonSpan.end(); const span = startSpan('bolt.google_pay.request_payment', { [BoltAttributes.PAYMENT_METHOD]: 'google_pay', @@ -147,30 +145,30 @@ export const GoogleWallet = ({ billingAddress: raw.billingAddress, }; - const tokenizeSpan = startSpan('bolt.google_pay.tokenize_success', { - [BoltAttributes.PAYMENT_METHOD]: 'google_pay', - [BoltAttributes.PAYMENT_OPERATION]: 'tokenize', - }); - tokenizeSpan.setStatus({ code: SpanStatusCode.OK }); - tokenizeSpan.end(); - + 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; + } - const tokenizeSpan = startSpan('bolt.google_pay.tokenize_failure', { - [BoltAttributes.PAYMENT_METHOD]: 'google_pay', - [BoltAttributes.PAYMENT_OPERATION]: 'tokenize', + span.addEvent('bolt.google_pay.tokenize_failure', { [BoltAttributes.ERROR_MESSAGE]: error.message, }); - tokenizeSpan.setStatus({ - code: SpanStatusCode.ERROR, - message: error.message, - }); - tokenizeSpan.end(); - span.setStatus({ code: SpanStatusCode.ERROR, message: error.message }); span.recordException(error); span.end(); 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(); +};