Skip to content
This repository has been archived by the owner on Aug 30, 2023. It is now read-only.

draft about distinct id implementation #342

Draft
wants to merge 2 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package io.sentry.android.core;

import android.content.Context;
import android.content.SharedPreferences;
import android.content.pm.PackageInfo;
import io.sentry.core.EnvelopeReader;
import io.sentry.core.IEnvelopeReader;
Expand All @@ -9,14 +10,18 @@
import io.sentry.core.SentryOptions;
import io.sentry.core.util.Objects;
import java.io.File;
import java.util.UUID;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

/**
* Android Options initializer, it reads configurations from AndroidManifest and sets to the
* SentryOptions. It also adds default values for some fields.
*/
final class AndroidOptionsInitializer {

private static final String SENTRY_DEVICE_ID = "sentry_device_id";

/** private ctor */
private AndroidOptionsInitializer() {}

Expand Down Expand Up @@ -68,9 +73,24 @@ static void init(
options.addIntegration(new AnrIntegration());
options.addIntegration(new SessionTrackingIntegration());

readDefaultOptionValues(options, context);
final SharedPreferences sharedPreferences =
context.getSharedPreferences(BuildConfig.LIBRARY_PACKAGE_NAME, Context.MODE_PRIVATE);

String sentryDeviceId;
// cache it if not set
if (!sharedPreferences.contains(SENTRY_DEVICE_ID)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the place to have this code on some package private static class? If so we could just call it from here.

I believe this contains call could be replace with the sharedPreferences.getString(SENTRY_DEVICE_ID, null) != null to avoid two I/O calls.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SharedPreferences does in-memory caching sync and it flushes to the disk async., so unless its value has changed, it's not an issue.
I'd separate it in a method or class, yes.

sentryDeviceId = UUID.randomUUID().toString();
// set default sentry device id
sharedPreferences.edit().putString(SENTRY_DEVICE_ID, sentryDeviceId).apply();
} else {
sentryDeviceId = sharedPreferences.getString(SENTRY_DEVICE_ID, null);
}

options.setUserCache(new AndroidUserCache(options, sharedPreferences, sentryDeviceId));

readDefaultOptionValues(options, context, sentryDeviceId);

options.addEventProcessor(new DefaultAndroidEventProcessor(context, options));
options.addEventProcessor(new DefaultAndroidEventProcessor(context, options, sentryDeviceId));

options.setSerializer(new AndroidSerializer(options.getLogger(), envelopeReader));

Expand All @@ -84,7 +104,9 @@ static void init(
* @param context the Android context methods
*/
private static void readDefaultOptionValues(
final @NotNull SentryAndroidOptions options, final @NotNull Context context) {
final @NotNull SentryAndroidOptions options,
final @NotNull Context context,
final @Nullable String sentryDeviceId) {
final PackageInfo packageInfo = ContextUtils.getPackageInfo(context, options.getLogger());
if (packageInfo != null) {
// Sets App's release if not set by Manifest
Expand All @@ -101,11 +123,7 @@ private static void readDefaultOptionValues(
}

if (options.getDistinctId() == null) {
try {
options.setDistinctId(Installation.id(context));
} catch (RuntimeException e) {
options.getLogger().log(SentryLevel.ERROR, "Could not generate distinct Id.", e);
}
options.setDistinctId(sentryDeviceId);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package io.sentry.android.core;

import android.content.SharedPreferences;
import io.sentry.core.IUserCache;
import io.sentry.core.protocol.User;
import io.sentry.core.util.Objects;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

// TODO: to think, this could be a SentryOptionsCache that could be expanded, does it make sense?
Copy link
Contributor Author

@marandaneto marandaneto Apr 6, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to discuss, related to the naming and scope of this class (IUserCache/AndroidUserCache)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense to me to keep this focused on user caching, specially the abstraction

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if Cache is the right naming here, because we actually persist the data to the SharedPreferences. A cache is usually used to improve the performance of something, see CPU caches, browser caches, web server caches. For me it is more like a UserRepo or UserStorage. What do you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not entirely, I borrowed the idea from https://developer.android.com/reference/android/content/Context.html#getCacheDir()

I see the Caching term as something temporary, being in memory or in the disk.
Also, https://docs.sentry.io/clients/java/integrations/#android (old SDK version)

Events will be buffered to disk (in the application’s cache directory) by default

I'm fine changing it as well, but it might make it inconsistent with other SDKs.

@bruno-garcia opnions?


/** the Android User Cache class. It caches the user data in the Android SharedPreferences */
public final class AndroidUserCache implements IUserCache {

// TODO: only these 3 fields make sense to me, otherwise caching a raw json would be better, but I
// don't want to do that.
private static final String EMAIL = "email";
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we prefix those constants here? Could it be that the users of our SDK also save email to their shared preferences? What about 'sentry_user_cache_email' or something similar?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

private static final String ID = "id";
private static final String USERNAME = "user_name";
Comment on lines +15 to +19
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to discuss, for sessions, we only need these 3 fields


private final @NotNull SharedPreferences sharedPreferences;
private final @NotNull SentryAndroidOptions options;
private final @Nullable String sentryDeviceId;

public AndroidUserCache(
final @NotNull SentryAndroidOptions options,
final @NotNull SharedPreferences sharedPreferences,
final @Nullable String sentryDeviceId) {
this.options = Objects.requireNonNull(options, "SentryAndroidOptions is required.");
this.sharedPreferences =
Objects.requireNonNull(sharedPreferences, "SharedPreferences is required.");
this.sentryDeviceId = sentryDeviceId;
}

// TODO: I believe this makes sense only for global hub SDKs, but if not,
// how do we cache multiple users (because we have multiple scopes)?
Comment on lines +35 to +36
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to discuss

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For me it makes sense to keep this class only specific to Android. I guess we are not going to run into the problem of caching multiple users.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep, this one is Android-specific, but we can't forget that sentry-core is cross-platform.
nothing to do here for now.


@Override
public void setUser(final @Nullable User user) {
if (options.isCacheUserForSessions()) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we could separate the logic of whether the user data should be saved or nor from the actual Android code. I would imagine two classes. One called something like UserCache that holds SentryAndroidOptions and SentryDeviceId and this class task to a simple interface like UserPersistance, that has an Android implementation an actually saves the user's data to the SharedPreferences. Then we have the SDK logic separated from persisting the data to Android. It would be easier to test and easier to extend. What do you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep, we could do that, just not sure how deep this abstraction should be, do we really see this user caching thing going forward with other SDKs? like java-backend, java-desktop etc... otherwise, it's just more classes for a single block of code, which is an if.
@bruno-garcia opinions?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It could be pulled out from the Android code but I believe it would only make sense then in desktop apps, right? Since this User in server apps are usually one per request and doesn't really make sense to cache.
Tbh we're not even sure this feature will even be used, it's just a work around to support distinctId for sessions based on user defined user id.

The proposal for distinctId has an option of not dealing with this at all (using only the generated id) which sounds like a good first version until we can get customer feedback to make a more educated decision.

final SharedPreferences.Editor edit = sharedPreferences.edit();
if (user != null) {
edit.putString(ID, user.getId())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we settle for only caching the userId instead? Or at least a single value (when a user is set, we evaluate the which value based on an order (id>username>email)? Would be worth seeing what the issues page already considers when accounting for "user" of a crash.

If we're expecting to store the actual User, we'd need to consider the protocol can change (versioning/new fields added), user define data and makes it trickier:

All other keys are stored as extra information but not specifically processed by Sentry.

If we're storing just a single value to be used for session tracking only, it would minimize these edge cases.
Everything else you said stands true:

setUser(...) makes the SDK evaluate a new value for session distrinctId. setUser(null) deletes that cached value.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep, a single value would be ok, makes sense.
the order would be id > email > username if following the release health specs.

.putString(EMAIL, user.getEmail())
.putString(USERNAME, user.getUsername())
Comment on lines +43 to +45
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For me it is a bit confusing why we pass the whole user to IUserCache.setUser and only save id, email and username. Why don't we save the other users properties?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we could, but our session guideline only considers id, email, and username as a valid unique identifier.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe then we should create an extra class for this and name it something like SessionIdCache and add this as a comment?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe the Caching class should not be aware of what id it is, by that I mean (id, email or username).
maybe this class should take a String nominated as distinctId and that's all, so the naming would not matter.
A session also has an id so SessionIdCache would not fit, as we are caching the Session.distinctId which is a unique identifier of the user or device.
UserIdCache would make sense though?! naming is hard hehe

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe this class should take a String nominated as distinctId

I prefer this too. It says what it does. So when setting the user, if this option is enabled, we select the data from the user object (id, username, email) to become the distinctId and cache that so the caching itself isn't aware of this logic and we don't have to deal with caching the actual user object and possible user-defined fields.

.apply(); // it does in-memory cache and flush to the disk async.
} else {
cleanUserFields(edit);
}
}
}

@Override
public @Nullable User getUser() {
// sharedPreferences has in-memory cache and flush to the disk async., so it's not expensive
if (options.isCacheUserForSessions()) {
final String id = sharedPreferences.getString(ID, null);
final String email = sharedPreferences.getString(EMAIL, null);
final String userName = sharedPreferences.getString(USERNAME, null);

boolean hasCachedUser = true;
if (id == null && email == null && userName == null) {
hasCachedUser = false;
}

final User user = new User();

if (hasCachedUser) {
// returns previous set user, even if it has no id
user.setId(id);
user.setEmail(email);
user.setUsername(userName);
} else {
user.setId(sentryDeviceId);
}
return user;
} else {
final SharedPreferences.Editor edit = sharedPreferences.edit();
// is it a good idea to do it here? this guarantees that it cleans up on restart if the flag
// has been swapped
cleanUserFields(edit);
Comment on lines +79 to +81
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to discuss

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it makes sense, because otherwise you could receive an old user. Imagine the following scenario:

  1. setUser(A)
  2. cacheUserForSessions = false
  3. setUser(B), which doesn't do anything.
  4. cacheUserForSessions = true
  5. getUser() returns A

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep, that was exactly the use-case

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just came up with another scenario:

  1. setUser(A)
  2. cacheUserForSessions = false
  3. cacheUserForSessions = true
  4. getUser() returns A

In this scenario, even with calling cleanUserFields in setUser and getUser, you get the old user A. SentryOptions.setCacheUserForSessions could clear the UserCache if set to false to solve this problem.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, I'd say on SDK. init, if the flag is false, we should clean this up.
SentryOptions fields are only mutable on SDK init., but not on runtime.
good catch

return null;
}
}

private void cleanUserFields(final @NotNull SharedPreferences.Editor edit) {
// do not clean SENTRY_DEVICE_ID
edit.remove(ID).remove(EMAIL).remove(USERNAME).apply();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import android.content.Context;
import android.content.pm.PackageInfo;
import android.os.Build;
import android.provider.Settings;
import io.sentry.core.ILogger;
import io.sentry.core.SentryLevel;
import org.jetbrains.annotations.NotNull;
Expand Down Expand Up @@ -45,4 +46,24 @@ static String getVersionCode(final @NotNull PackageInfo packageInfo) {
private static @NotNull String getVersionCodeDep(final @NotNull PackageInfo packageInfo) {
return Integer.toString(packageInfo.versionCode);
}

/**
* Returns the Settings.Secure.ANDROID_ID and if not valid, fallback to defaultValue
*
* @param defaultValue the defaultValue
* @return Settings.Secure.ANDROID_ID if valid or defaultValue
*/
@SuppressWarnings("HardwareIds")
static @Nullable String getAndroidId(
final @NotNull Context context, final @Nullable String defaultValue) {
String androidId =
Settings.Secure.getString(context.getContentResolver(), Settings.Secure.ANDROID_ID);

if (androidId == null
|| androidId.isEmpty()
|| androidId.equalsIgnoreCase("9774d56d682e549c")) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we maybe add a comment about what 9774d56d682e549c is? I had to Google it ;)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ups, copy-pasta mistake.
the original code has a comment which leads to the google blogpost
see

// https://android-developers.googleblog.com/2011/03/identifying-app-installations.html

return defaultValue;
}
return androidId;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@
import io.sentry.core.EventProcessor;
import io.sentry.core.SentryEvent;
import io.sentry.core.SentryLevel;
import io.sentry.core.SentryOptions;
import io.sentry.core.protocol.App;
import io.sentry.core.protocol.DebugImage;
import io.sentry.core.protocol.DebugMeta;
Expand Down Expand Up @@ -61,7 +60,6 @@ final class DefaultAndroidEventProcessor implements EventProcessor {

@TestOnly static final String PROGUARD_UUID = "proGuardUuids";
@TestOnly static final String ROOTED = "rooted";
@TestOnly static final String ANDROID_ID = "androidId";
@TestOnly static final String KERNEL_VERSION = "kernelVersion";
@TestOnly static final String EMULATOR = "emulator";

Expand All @@ -70,14 +68,19 @@ final class DefaultAndroidEventProcessor implements EventProcessor {

@TestOnly final Context context;

private final SentryOptions options;
private final SentryAndroidOptions options;

@TestOnly final Future<Map<String, Object>> contextData;

private final @Nullable String sentryDeviceId;

public DefaultAndroidEventProcessor(
final @NotNull Context context, final @NotNull SentryOptions options) {
final @NotNull Context context,
final @NotNull SentryAndroidOptions options,
final @Nullable String sentryDeviceId) {
this.context = Objects.requireNonNull(context, "The application context is required.");
this.options = Objects.requireNonNull(options, "The SentryOptions is required.");
this.options = Objects.requireNonNull(options, "The SentryAndroidOptions is required.");
this.sentryDeviceId = sentryDeviceId;

ExecutorService executorService = Executors.newSingleThreadExecutor();
// dont ref. to method reference, theres a bug on it
Expand All @@ -95,11 +98,6 @@ public DefaultAndroidEventProcessor(

map.put(ROOTED, isRooted());

String androidId = getAndroidId();
if (androidId != null) {
map.put(ANDROID_ID, androidId);
}

String kernelVersion = getKernelVersion();
if (kernelVersion != null) {
map.put(KERNEL_VERSION, kernelVersion);
Expand Down Expand Up @@ -137,8 +135,14 @@ public DefaultAndroidEventProcessor(

// Data to be applied to events that was created in the running process
private void processNonCachedEvent(final @NotNull SentryEvent event) {
if (event.getUser() == null) {
event.setUser(getUser());
User user = event.getUser();
if (user == null) {
user = new User();
}
event.setUser(user);

if (user.getId() == null) {
user.setId(getDeviceId());
}

App app = event.getContexts().getApp();
Expand Down Expand Up @@ -839,47 +843,12 @@ private boolean isRooted() {
return null;
}

public @NotNull User getUser() {
User user = new User();
user.setId(getDeviceId());

return user;
}

private @Nullable String getDeviceId() {
try {
Object androidId = contextData.get().get(ANDROID_ID);

if (androidId != null) {
return (String) androidId;
}
} catch (Exception e) {
options.getLogger().log(SentryLevel.ERROR, "Error getting androidId.", e);
}
return null;
}

@SuppressWarnings("HardwareIds")
private @Nullable String getAndroidId() {
// Android 29 has changed and -> Avoid using hardware identifiers, find another way in the
// future
String androidId =
Settings.Secure.getString(context.getContentResolver(), Settings.Secure.ANDROID_ID);

// https://android-developers.googleblog.com/2011/03/identifying-app-installations.html
if (androidId == null
|| androidId.isEmpty()
|| androidId.toLowerCase(Locale.ROOT).contentEquals("9774d56d682e549c")) {
try {
androidId = Installation.id(context);
} catch (RuntimeException e) {
options.getLogger().log(SentryLevel.ERROR, "Could not generate device Id.", e);

return null;
}
public @Nullable String getDeviceId() {
if (options.isEnableSecureAndroidId()) {
return ContextUtils.getAndroidId(context, sentryDeviceId);
} else {
return sentryDeviceId;
}

return androidId;
}

private @Nullable String[] getProGuardUuids() {
Expand Down

This file was deleted.

Loading