Skip to content

Commit a73c6bf

Browse files
authored
feat(scopes): Support scopes on Android (#835)
* feat(scopes): Add scopes and SentrySDK.with_scope() on Android * Update CHANGELOG.md * Style and checks * Update class docs with Android support * Clarify sentinel value via code * Explain propagation context in combined scopes * Fix null handling in capture_feedback() * Add scope attribute type tests for metrics+logs * Add workaround for scope.clear() leaving contexts * Better scope.clear() tests * Remove null checks after static cast * Align scope parameter position in bridge
1 parent 7f4749f commit a73c6bf

14 files changed

Lines changed: 499 additions & 33 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44

55
### Features
66

7-
- Add current scope support to the GDScript API to enrich the telemetry captured within a specific part of the code ([#834](https://github.com/getsentry/sentry-godot/pull/834))
7+
- Add current scope support to the GDScript API to enrich the telemetry captured within a specific part of the code ([#834](https://github.com/getsentry/sentry-godot/pull/834), [#835](https://github.com/getsentry/sentry-godot/pull/835))
88
- `SentrySDK.with_scope()` runs a callable with a forked scope, `SentrySDK.get_current_scope()` returns the scope active on the calling thread, and the new `SentryScope` class carries tags, contexts, user, level, fingerprint, breadcrumbs, and attributes on top of the data set globally
9-
- Only supported on Windows and Linux for now, with the remaining platforms still capturing telemetry but discarding the scope data and printing a warning
9+
- Only supported on Windows, Linux, and Android for now, with the remaining platforms still capturing telemetry but discarding the scope data and printing a warning
1010

1111
### Dependencies
1212

android_lib/src/main/java/io/sentry/godotplugin/SentryAndroidGodotPlugin.kt

Lines changed: 193 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,13 @@ import android.util.Log
44
import io.sentry.Attachment
55
import io.sentry.Breadcrumb
66
import io.sentry.Hint
7+
import io.sentry.IScope
8+
import io.sentry.IScopes
79
import io.sentry.ISerializer
810
import io.sentry.JsonUnknown
11+
import io.sentry.Scope
12+
import io.sentry.ScopeType
13+
import io.sentry.Scopes
914
import io.sentry.Sentry
1015
import io.sentry.SentryAttributes
1116
import io.sentry.SentryEvent
@@ -17,7 +22,9 @@ import io.sentry.SentryMetricsEvent
1722
import io.sentry.SentryOptions
1823
import io.sentry.android.core.InternalSentrySdk
1924
import io.sentry.android.core.SentryAndroid
25+
import io.sentry.logger.ILoggerApi
2026
import io.sentry.logger.SentryLogParameters
27+
import io.sentry.metrics.IMetricsApi
2128
import io.sentry.metrics.SentryMetricsParameters
2229
import io.sentry.protocol.Feedback
2330
import io.sentry.protocol.Message
@@ -67,6 +74,18 @@ class SentryAndroidGodotPlugin(godot: Godot) : GodotPlugin(godot) {
6774
}
6875
}
6976

77+
private val scopesByHandle = object : ThreadLocal<MutableMap<Int, IScope>>() {
78+
override fun initialValue(): MutableMap<Int, IScope> {
79+
return mutableMapOf()
80+
}
81+
}
82+
83+
private val combinedScopesByHandle = object : ThreadLocal<MutableMap<Int, IScopes>>() {
84+
override fun initialValue(): MutableMap<Int, IScopes> {
85+
return mutableMapOf()
86+
}
87+
}
88+
7089
private fun getEvent(eventHandle: Int): SentryEvent? {
7190
val event: SentryEvent? = eventsByHandle.get()?.get(eventHandle)
7291
if (event == null) {
@@ -99,6 +118,40 @@ class SentryAndroidGodotPlugin(godot: Godot) : GodotPlugin(godot) {
99118
return metricEvent
100119
}
101120

121+
private fun getScope(scopeHandle: Int): IScope? {
122+
val scope: IScope? = scopesByHandle.get()?.get(scopeHandle)
123+
if (scope == null) {
124+
Log.e(TAG, "Internal Error -- Scope not found: $scopeHandle")
125+
}
126+
return scope
127+
}
128+
129+
// Returns combined scopes by the given scope handle (scope is owned by C++ layer).
130+
// This layers the current scope over the global and isolation scopes, so scope data applies per capture.
131+
// The trace is not layered: sentry-java resolves the propagation context through defaultScopeType, which we pin
132+
// to GLOBAL, so captures stay on the trace setTrace wrote there and a blank local scope never forks it.
133+
private fun combinedScopes(scopeHandle: Int): IScopes? {
134+
if (!Sentry.isEnabled()) {
135+
return null
136+
}
137+
val local = getScope(scopeHandle) ?: return null
138+
val cache = combinedScopesByHandle.get()
139+
cache?.get(scopeHandle)?.let { return it }
140+
val scopes = Sentry.getCurrentScopes()
141+
val combined =
142+
Scopes(local, scopes.isolationScope, scopes.globalScope, "SentryAndroidGodotPlugin.combinedScopes")
143+
cache?.put(scopeHandle, combined)
144+
return combined
145+
}
146+
147+
private fun scopedMetrics(scopeHandle: Int): IMetricsApi {
148+
return combinedScopes(scopeHandle)?.metrics() ?: Sentry.metrics()
149+
}
150+
151+
private fun scopedLogger(scopeHandle: Int): ILoggerApi {
152+
return combinedScopes(scopeHandle)?.logger() ?: Sentry.logger()
153+
}
154+
102155
private fun registerEvent(event: SentryEvent): Int {
103156
val eventsMap = eventsByHandle.get() ?: run {
104157
Log.e(TAG, "Internal Error -- eventsByHandle is null")
@@ -159,6 +212,21 @@ class SentryAndroidGodotPlugin(godot: Godot) : GodotPlugin(godot) {
159212
return handle
160213
}
161214

215+
private fun registerScope(scope: IScope): Int {
216+
val scopesMap = scopesByHandle.get() ?: run {
217+
Log.e(TAG, "Internal Error -- scopesByHandle is null")
218+
return 0
219+
}
220+
221+
var handle = Random.nextInt()
222+
while (handle == 0 || scopesMap.containsKey(handle)) {
223+
handle = Random.nextInt()
224+
}
225+
226+
scopesMap[handle] = scope
227+
return handle
228+
}
229+
162230
override fun getPluginName(): String {
163231
return "SentryAndroidGodotPlugin"
164232
}
@@ -204,6 +272,11 @@ class SentryAndroidGodotPlugin(godot: Godot) : GodotPlugin(godot) {
204272
options.isAttachAnrThreadDump = attachAnrThreadDump
205273
options.shutdownTimeoutMillis = shutdownTimeoutMs
206274
options.isTombstoneEnabled = true
275+
// Android top-level API writes to current scope by default.
276+
// Re-route it to global scope so Sentry.setTag/setUser/addBreadcrumb and the automatic integration
277+
// breadcrumbs land on the process-wide, native-synced global scope. Our layer owns the current slot for
278+
// local (with_scope) data, and captures merge global + local.
279+
options.defaultScopeType = ScopeType.GLOBAL
207280
options.beforeSend =
208281
SentryOptions.BeforeSendCallback { event: SentryEvent, hint: Hint ->
209282
Log.v(TAG, "beforeSend: ${event.eventId} isCrashed: ${event.isCrashed}")
@@ -323,12 +396,13 @@ class SentryAndroidGodotPlugin(godot: Godot) : GodotPlugin(godot) {
323396
}
324397

325398
@UsedByGodot
326-
fun log(level: Int, body: String, attributes: Dictionary) {
399+
fun log(scopeHandle: Int, level: Int, body: String, attributes: Dictionary) {
400+
val logger = scopedLogger(scopeHandle)
327401
if (attributes.isEmpty()) {
328-
Sentry.logger().log(level.toSentryLogLevel(), body)
402+
logger.log(level.toSentryLogLevel(), body)
329403
} else {
330404
val sentryAttributes = SentryAttributes.fromMap(attributes)
331-
Sentry.logger().log(
405+
logger.log(
332406
level.toSentryLogLevel(),
333407
SentryLogParameters.create(sentryAttributes),
334408
body
@@ -337,33 +411,35 @@ class SentryAndroidGodotPlugin(godot: Godot) : GodotPlugin(godot) {
337411
}
338412

339413
@UsedByGodot
340-
fun metricsAddCount(name: String, value: Long, attributes: Dictionary) {
414+
fun metricsAddCount(scopeHandle: Int, name: String, value: Long, attributes: Dictionary) {
415+
val metrics = scopedMetrics(scopeHandle)
341416
if (attributes.isEmpty()) {
342-
Sentry.metrics().count(name, value.toDouble())
417+
metrics.count(name, value.toDouble())
343418
} else {
344419
val sentryAttributes = SentryAttributes.fromMap(attributes)
345-
Sentry.metrics().count(name, value.toDouble(), null, SentryMetricsParameters.create(sentryAttributes))
420+
metrics.count(name, value.toDouble(), null, SentryMetricsParameters.create(sentryAttributes))
346421
}
347422
}
348423

349424
@UsedByGodot
350-
fun metricsAddGauge(name: String, value: Double, unit: String, attributes: Dictionary) {
425+
fun metricsAddGauge(scopeHandle: Int, name: String, value: Double, unit: String, attributes: Dictionary) {
426+
val metrics = scopedMetrics(scopeHandle)
351427
if (attributes.isEmpty()) {
352-
Sentry.metrics().gauge(name, value, unit.ifEmpty { null })
428+
metrics.gauge(name, value, unit.ifEmpty { null })
353429
} else {
354430
val sentryAttributes = SentryAttributes.fromMap(attributes)
355-
Sentry.metrics().gauge(name, value, unit.ifEmpty { null }, SentryMetricsParameters.create(sentryAttributes))
431+
metrics.gauge(name, value, unit.ifEmpty { null }, SentryMetricsParameters.create(sentryAttributes))
356432
}
357433
}
358434

359435
@UsedByGodot
360-
fun metricsAddDistribution(name: String, value: Double, unit: String, attributes: Dictionary) {
436+
fun metricsAddDistribution(scopeHandle: Int, name: String, value: Double, unit: String, attributes: Dictionary) {
437+
val metrics = scopedMetrics(scopeHandle)
361438
if (attributes.isEmpty()) {
362-
Sentry.metrics().distribution(name, value, unit.ifEmpty { null })
439+
metrics.distribution(name, value, unit.ifEmpty { null })
363440
} else {
364441
val sentryAttributes = SentryAttributes.fromMap(attributes)
365-
Sentry.metrics()
366-
.distribution(name, value, unit.ifEmpty { null }, SentryMetricsParameters.create(sentryAttributes))
442+
metrics.distribution(name, value, unit.ifEmpty { null }, SentryMetricsParameters.create(sentryAttributes))
367443
}
368444
}
369445

@@ -420,24 +496,26 @@ class SentryAndroidGodotPlugin(godot: Godot) : GodotPlugin(godot) {
420496
}
421497

422498
@UsedByGodot
423-
fun captureEvent(eventHandle: Int): String {
499+
fun captureEvent(scopeHandle: Int, eventHandle: Int): String {
424500
val event: SentryEvent = getEvent(eventHandle) ?: run {
425501
Log.e(TAG, "Failed to capture event: $eventHandle")
426502
return ""
427503
}
428-
val id = Sentry.captureEvent(event)
504+
val scopes = combinedScopes(scopeHandle)
505+
val id = scopes?.captureEvent(event) ?: Sentry.captureEvent(event)
429506
return id.toString()
430507
}
431508

432509
@UsedByGodot
433-
fun captureFeedback(message: String, contactEmail: String, name: String, associatedEventId: String) {
510+
fun captureFeedback(scopeHandle: Int, message: String, contactEmail: String, name: String, associatedEventId: String) {
434511
val feedback = Feedback(message)
435512
feedback.contactEmail = contactEmail.ifEmpty { null }
436513
feedback.name = name.ifEmpty { null }
437514
if (associatedEventId.isNotEmpty()) {
438515
feedback.setAssociatedEventId(SentryId(associatedEventId))
439516
}
440-
Sentry.feedback().capture(feedback)
517+
val feedbackApi = combinedScopes(scopeHandle)?.feedback() ?: Sentry.feedback()
518+
feedbackApi.capture(feedback)
441519
}
442520

443521
@UsedByGodot
@@ -772,6 +850,104 @@ class SentryAndroidGodotPlugin(godot: Godot) : GodotPlugin(godot) {
772850
return crumb.timestamp.toMicros()
773851
}
774852

853+
@UsedByGodot
854+
fun createScope(): Int {
855+
return registerScope(Scope(Sentry.getCurrentScopes().options))
856+
}
857+
858+
@UsedByGodot
859+
fun cloneScope(handle: Int): Int {
860+
val scope = getScope(handle) ?: return 0
861+
return registerScope(scope.clone())
862+
}
863+
864+
@UsedByGodot
865+
fun releaseScope(handle: Int) {
866+
scopesByHandle.get()?.remove(handle)
867+
combinedScopesByHandle.get()?.remove(handle)
868+
}
869+
870+
@UsedByGodot
871+
fun scopeSetContext(handle: Int, key: String, value: Dictionary) {
872+
getScope(handle)?.setContexts(key, value)
873+
}
874+
875+
@UsedByGodot
876+
fun scopeSetTag(handle: Int, key: String, value: String) {
877+
getScope(handle)?.setTag(key, value)
878+
}
879+
880+
@UsedByGodot
881+
fun scopeSetUser(handle: Int, id: String, userName: String, email: String, ipAddress: String) {
882+
val scope = getScope(handle) ?: return
883+
val user = User()
884+
if (id.isNotEmpty()) {
885+
user.id = id
886+
}
887+
if (userName.isNotEmpty()) {
888+
user.username = userName
889+
}
890+
if (email.isNotEmpty()) {
891+
user.email = email
892+
}
893+
if (ipAddress.isNotEmpty()) {
894+
user.ipAddress = ipAddress
895+
}
896+
scope.user = user
897+
}
898+
899+
@UsedByGodot
900+
fun scopeRemoveUser(handle: Int) {
901+
getScope(handle)?.user = null
902+
}
903+
904+
@UsedByGodot
905+
fun scopeSetLevel(handle: Int, level: Int) {
906+
getScope(handle)?.level = level.toSentryLevel()
907+
}
908+
909+
@UsedByGodot
910+
fun scopeSetFingerprint(handle: Int, fingerprint: Array<String>) {
911+
getScope(handle)?.fingerprint = fingerprint.toList()
912+
}
913+
914+
@UsedByGodot
915+
fun scopeSetAttributeBool(handle: Int, name: String, value: Boolean) {
916+
getScope(handle)?.setAttribute(name, value)
917+
}
918+
919+
@UsedByGodot
920+
fun scopeSetAttributeLong(handle: Int, name: String, value: Long) {
921+
getScope(handle)?.setAttribute(name, value)
922+
}
923+
924+
@UsedByGodot
925+
fun scopeSetAttributeDouble(handle: Int, name: String, value: Double) {
926+
getScope(handle)?.setAttribute(name, value)
927+
}
928+
929+
@UsedByGodot
930+
fun scopeSetAttributeString(handle: Int, name: String, value: String) {
931+
getScope(handle)?.setAttribute(name, value)
932+
}
933+
934+
@UsedByGodot
935+
fun scopeAddBreadcrumb(scopeHandle: Int, crumbHandle: Int) {
936+
val scope = getScope(scopeHandle) ?: return
937+
val crumb = getBreadcrumb(crumbHandle) ?: return
938+
scope.addBreadcrumb(crumb)
939+
}
940+
941+
@UsedByGodot
942+
fun scopeClear(handle: Int) {
943+
val scope = getScope(handle) ?: return
944+
scope.clear()
945+
// WORKAROUND: Scope.clear() leaves contexts in place, so drop them here to match the other platforms.
946+
for (key in scope.contexts.entrySet().map { it.key }) {
947+
scope.removeContexts(key)
948+
}
949+
}
950+
775951
@UsedByGodot
776952
fun releaseLog(handle: Int) {
777953
val logsMap = logsByHandle.get() ?: run {

doc_classes/SentrySDK.xml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@
7777
<description>
7878
Returns the current scope on the calling thread. Inside a [method SentrySDK.with_scope] callable, this is the scope forked for that callable. The fork is discarded when the callable returns, and the parent scope it was forked from becomes current again. Outside of any [method SentrySDK.with_scope], the returned scope stays current for the telemetry captured later on that thread.
7979
The returned scope belongs to the calling thread. Data written to it enriches only the telemetry captured on that thread, and modifying it from another thread is not supported. See [SentryScope] for details. To enrich telemetry captured on every thread, use the [SentrySDK] methods such as [method SentrySDK.set_tag] instead.
80-
[b]Note:[/b] The SDK supports scopes on Windows and Linux only for now. On the other platforms it still captures telemetry, but discards the data written to the returned scope.
80+
[b]Note:[/b] The SDK supports scopes on Windows, Linux, and Android only for now. On the other platforms it still captures telemetry, but discards the data written to the returned scope.
8181
</description>
8282
</method>
8383
<method name="get_last_event_id" qualifiers="const">
@@ -209,7 +209,7 @@
209209
Writes made through [SentrySDK] methods such as [method SentrySDK.set_tag] still apply globally, even when called inside the callable.
210210
For more information, see [SentryScope] class.
211211
[b]Note:[/b] The fork covers the synchronous part of [param callable] only. If the callable awaits, the fork is discarded at the first [code]await[/code] and a warning is printed.
212-
[b]Note:[/b] The SDK supports scopes on Windows and Linux only for now. On the other platforms it still captures telemetry, but discards the data written to the forked scope and prints a warning.
212+
[b]Note:[/b] The SDK supports scopes on Windows, Linux, and Android only for now. On the other platforms it still captures telemetry, but discards the data written to the forked scope and prints a warning.
213213
</description>
214214
</method>
215215
</methods>

doc_classes/SentryScope.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
)
1515
[/codeblock]
1616
[b]Note:[/b] A scope is thread-local. Modify a scope only from the thread that created it. If a scope is modified from another thread, the SDK rejects the call with an error and discards the data. To enrich telemetry captured on another thread, get that thread's current scope with [method SentrySDK.get_current_scope], or use [SentrySDK] methods such as [method SentrySDK.set_tag] to enrich telemetry captured on all threads.
17-
[b]Note:[/b] The SDK supports scopes on Windows and Linux only for now. On the other platforms it still captures telemetry, but discards the data written to the scope.
17+
[b]Note:[/b] The SDK supports scopes on Windows, Linux, and Android only for now. On the other platforms it still captures telemetry, but discards the data written to the scope.
1818
</description>
1919
<tutorials>
2020
</tutorials>

project/test/isolated/test_metrics.gd

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ func test_before_send_metric_discard() -> void:
170170

171171

172172
# TODO: remove skip when implemented on other platforms
173-
func test_metric_with_scope_attributes(_do_skip = OS.get_name() not in ["Windows", "Linux"]) -> void:
173+
func test_metric_with_scope_attributes(_do_skip = OS.get_name() not in ["Windows", "Linux", "Android"]) -> void:
174174
SentrySDK.set_attribute("from_global", "global")
175175
SentrySDK.set_attribute("scope_over_global", "global")
176176
SentrySDK.set_attribute("metric_over_all", "global")
@@ -202,3 +202,22 @@ func test_metric_with_scope_attributes(_do_skip = OS.get_name() not in ["Windows
202202
assert_str(metric.get_attribute("scope_over_global")).is_equal("global")
203203
, CONNECT_ONE_SHOT)
204204
SentrySDK.metrics.count("metric_after_scope")
205+
206+
207+
# TODO: remove skip when implemented on other platforms
208+
func test_metric_with_scope_attribute_types(_do_skip = OS.get_name() not in ["Windows", "Linux", "Android"]) -> void:
209+
SentrySDK.with_scope(func(scope: SentryScope):
210+
scope.set_attribute("level", "forest")
211+
scope.set_attribute("enemy_id", 42)
212+
scope.set_attribute("health", 10.5)
213+
scope.set_attribute("elite", false)
214+
215+
metric_processed.connect(func(metric: SentryMetric):
216+
assert_str(metric.get_attribute("level")).is_equal("forest")
217+
assert_int(metric.get_attribute("enemy_id")).is_equal(42)
218+
assert_float(metric.get_attribute("health")).is_equal_approx(10.5, 0.001)
219+
assert_bool(metric.get_attribute("elite")).is_equal(false)
220+
, CONNECT_ONE_SHOT)
221+
222+
SentrySDK.metrics.count("scoped_metric_types")
223+
)

0 commit comments

Comments
 (0)