diff --git a/apps/flipcash/shared/google-play-billing/src/main/kotlin/com/flipcash/app/billing/internal/GooglePlayBillingClient.kt b/apps/flipcash/shared/google-play-billing/src/main/kotlin/com/flipcash/app/billing/internal/GooglePlayBillingClient.kt index 59f3c2940d..ccccc50081 100644 --- a/apps/flipcash/shared/google-play-billing/src/main/kotlin/com/flipcash/app/billing/internal/GooglePlayBillingClient.kt +++ b/apps/flipcash/shared/google-play-billing/src/main/kotlin/com/flipcash/app/billing/internal/GooglePlayBillingClient.kt @@ -106,7 +106,7 @@ internal class GooglePlayBillingClient( metadata = { "code" to billingResult.responseCode "message" to billingResult.debugMessage - "purchases" to purchases?.count() + "purchases" to (purchases?.count() ?: -1) } ) diff --git a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt index 02805ef5ca..c60f0f6122 100644 --- a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt +++ b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt @@ -139,8 +139,10 @@ class NotificationService : FirebaseMessagingService(), message = "onMessageReceived", type = TraceType.Process, metadata = { - "title" to title - "body" to body + // Push content is not recorded: TraceType.Process is forwarded to + // breadcrumb sinks, and message text does not belong in Bugsnag. + "silent" to (title == null) + "has_body" to (body != null) } ) diff --git a/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampController.kt b/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampController.kt index 525bee8bc7..8f9b1f289d 100644 --- a/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampController.kt +++ b/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampController.kt @@ -324,9 +324,9 @@ class CoinbaseOnRampController @Inject constructor( "orderId" to orderId "httpCode" to error.code().toString() "errorType" to (coinbaseError?.let { it::class.simpleName } ?: "unknown") - "correlationId" to coinbaseError?.correlationId - "responseBody" to errorBody - "errorLink" to coinbaseError?.errorLink + "correlationId" to coinbaseError?.correlationId.orEmpty() + "responseBody" to errorBody.orEmpty() + "errorLink" to coinbaseError?.errorLink.orEmpty() }, error = error, ) diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt index 9e4d5b4ffd..397a2915ff 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt @@ -1860,7 +1860,7 @@ class SwapViewModel @Inject constructor( message = "Something went wrong during phantom onramp", type = TraceType.Error, metadata = { - "errorMessage" to deeplinkError.message + "errorMessage" to deeplinkError.message.orEmpty() "code" to deeplinkError.code }, error = deeplinkError.takeUnless { it.isAlert } diff --git a/apps/flipcash/shared/workers/src/main/kotlin/com/flipcash/app/workers/internal/GiftCardFundingWorker.kt b/apps/flipcash/shared/workers/src/main/kotlin/com/flipcash/app/workers/internal/GiftCardFundingWorker.kt index 5cc214d267..673bd67752 100644 --- a/apps/flipcash/shared/workers/src/main/kotlin/com/flipcash/app/workers/internal/GiftCardFundingWorker.kt +++ b/apps/flipcash/shared/workers/src/main/kotlin/com/flipcash/app/workers/internal/GiftCardFundingWorker.kt @@ -16,6 +16,7 @@ import com.getcode.opencode.model.accounts.entropy import com.getcode.opencode.model.financial.LocalFiat import com.getcode.opencode.model.financial.Token import com.getcode.solana.keys.Mint +import com.getcode.solana.keys.base58 import com.getcode.utils.trace import dagger.assisted.Assisted import dagger.assisted.AssistedInject @@ -148,7 +149,10 @@ internal class GiftCardFundingWorker @AssistedInject constructor( tag = "GiftCardFundingWorker", message = "Successfully funded gift card", metadata = { - "giftCard" to giftCard.entropy + // The vault address identifies the gift card without + // exposing its entropy, which is the seed controlling + // its funds. This trace reaches breadcrumb sinks. + "giftCardVault" to giftCard.cluster.vaultPublicKey.base58() } ) cont.resume(kotlin.Result.success(it)) @@ -158,7 +162,7 @@ internal class GiftCardFundingWorker @AssistedInject constructor( tag = "GiftCardFundingWorker", message = "Failed to fund gift card", metadata = { - "giftCard" to giftCard.entropy + "giftCardVault" to giftCard.cluster.vaultPublicKey.base58() }, error = it ) diff --git a/libs/logging/src/main/kotlin/com/getcode/utils/Logging.kt b/libs/logging/src/main/kotlin/com/getcode/utils/Logging.kt index c3e048f62c..636d9e5681 100644 --- a/libs/logging/src/main/kotlin/com/getcode/utils/Logging.kt +++ b/libs/logging/src/main/kotlin/com/getcode/utils/Logging.kt @@ -346,11 +346,25 @@ suspend fun timedTraceSuspend( class MetadataBuilder { private val map = mutableMapOf() - infix fun String.to(value: Any) { - map[this] = value + /** + * Records [value] under this key, substituting [NULL_PLACEHOLDER] when it is `null`. + * + * The parameter is deliberately nullable. A non-null `Any` parameter makes this + * function inapplicable to a nullable argument, so the call silently resolves to + * [kotlin.to] instead, building a [Pair] that is discarded in statement position — + * the field is dropped with no error and no warning. Accepting `Any?` keeps this + * member the only candidate, so every pair is recorded. + */ + infix fun String.to(value: Any?) { + map[this] = value ?: NULL_PLACEHOLDER } fun build(): Map = map + + companion object { + /** Recorded in place of a `null` metadata value so the field stays visible. */ + const val NULL_PLACEHOLDER = "null" + } } /** Convenience factory that builds a metadata map from [block]. */ diff --git a/libs/logging/src/test/kotlin/com/getcode/utils/MetadataBuilderTest.kt b/libs/logging/src/test/kotlin/com/getcode/utils/MetadataBuilderTest.kt new file mode 100644 index 0000000000..1e4009003d --- /dev/null +++ b/libs/logging/src/test/kotlin/com/getcode/utils/MetadataBuilderTest.kt @@ -0,0 +1,103 @@ +package com.getcode.utils + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Guards the overload resolution of [MetadataBuilder.to]. + * + * When the parameter was a non-null `Any`, a nullable argument made the member + * inapplicable and the call resolved to [kotlin.to] instead — building a [Pair] + * that was discarded in statement position. The field was dropped silently, with + * no error and at most an unused-expression warning. These tests fail if the + * parameter is ever narrowed back to a non-null type. + */ +class MetadataBuilderTest { + + private fun build(block: MetadataBuilder.() -> Unit): Map = + MetadataBuilder().apply(block).build() + + @Test + fun `nullable String holding a value is recorded`() { + val present: String? = "hello" + + val result = build { "value" to present } + + assertEquals(mapOf("value" to "hello"), result) + } + + @Test + fun `nullable String holding null is recorded as the placeholder`() { + val absent: String? = null + + val result = build { "value" to absent } + + assertEquals(mapOf("value" to MetadataBuilder.NULL_PLACEHOLDER), result) + } + + @Test + fun `both nullable cases appear in the same map`() { + val present: String? = "hello" + val absent: String? = null + + val result = build { + "present" to present + "absent" to absent + } + + assertEquals( + mapOf("present" to "hello", "absent" to MetadataBuilder.NULL_PLACEHOLDER), + result, + ) + } + + @Test + fun `non-null values are recorded unchanged`() { + val result = build { + "string" to "text" + "int" to 1 + "boolean" to true + } + + assertEquals(mapOf("string" to "text", "int" to 1, "boolean" to true), result) + } + + @Test + fun `nullable non-String types are recorded`() { + val count: Int? = null + val flag: Boolean? = false + + val result = build { + "count" to count + "flag" to flag + } + + assertEquals( + mapOf("count" to MetadataBuilder.NULL_PLACEHOLDER, "flag" to false), + result, + ) + } + + @Test + fun `every pair reaches the map`() { + val absent: String? = null + + val result = build { + "a" to "one" + "b" to absent + "c" to 3 + } + + assertEquals(3, result.size) + } + + @Test + fun `a repeated key keeps the last value`() { + val result = build { + "key" to "first" + "key" to "second" + } + + assertEquals(mapOf("key" to "second"), result) + } +} diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/exchange/RealVerifiedFiatCalculator.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/exchange/RealVerifiedFiatCalculator.kt index 2eda845e6e..6767901e9b 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/internal/exchange/RealVerifiedFiatCalculator.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/internal/exchange/RealVerifiedFiatCalculator.kt @@ -171,7 +171,7 @@ internal class RealVerifiedFiatCalculator @Inject constructor( "original currency fx" to rate.fx "requested amount" to amount.formatted() "requested quarks (in USD)" to usdValue.quarks * 1_000_000 - "balance quarks (in USD)" to balance?.quarks?.times(1_000_000) + "balance quarks (in USD)" to (balance?.quarks?.times(1_000_000) ?: "none") "capped quarks (in USD)" to cappedValue.quarks * 1_000_000 "supply of ${token.symbol}" to supply "calculated quarks" to quarks