Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support multi client storage #290

Closed
wants to merge 8 commits into from
Closed
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 2 additions & 2 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
GIT
remote: git@github.com:bugsnag/maze-runner
revision: 9f5b7f220441d047888ffdd91bd6b32be6367078
revision: f7123450d5a75b719911c6dd3baa0507e6062c2d
specs:
bugsnag-maze-runner (1.0.0)
cucumber (~> 3.1.0)
Expand All @@ -12,7 +12,7 @@ GIT
GEM
remote: https://rubygems.org/
specs:
backports (3.11.2)
backports (3.11.3)
builder (3.2.3)
coderay (1.1.2)
cucumber (3.1.0)
Expand Down
2 changes: 1 addition & 1 deletion features/multi_client.feature
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,4 @@ Scenario: Sessions while captured offline is detected by two clients with differ

And the "Bugsnag-API-Key" header equals "a35a2a72bd230ac0aa0f52715bbdc6aa" for request 0
And the payload field "sessions.0.user.name" equals "Bob" for request 0
And the payload field "sessions" is an array with 1 element for request 10
And the payload field "sessions" is an array with 1 element for request 0
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ internal class MultiClientSessionScenario(config: Configuration,
secondClient!!.startSession()
} else {
flushAllSessions(firstClient!!)
Thread.sleep(10) // enforce request order
Thread.sleep(50) // enforce request order
flushAllSessions(secondClient!!)
}
}
Expand Down
21 changes: 17 additions & 4 deletions sdk/src/androidTest/java/com/bugsnag/android/ErrorStoreTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import java.io.File;
import java.io.IOException;
import java.io.StringWriter;
import java.util.List;

@RunWith(AndroidJUnit4.class)
@SmallTest
Expand All @@ -42,9 +43,8 @@ public void setUp() throws Exception {
Client client = new Client(InstrumentationRegistry.getContext(), "api-key");
config = client.config;
errorStore = client.errorStore;
Assert.assertNotNull(errorStore.storeDirectory);
errorStorageDir = new File(errorStore.storeDirectory);
FileUtils.clearFilesInDir(errorStorageDir);
errorStorageDir = errorStore.storageDir;
FileUtils.clearFiles(errorStore);
}

/**
Expand All @@ -54,7 +54,7 @@ public void setUp() throws Exception {
*/
@After
public void tearDown() throws Exception {
FileUtils.clearFilesInDir(errorStorageDir);
FileUtils.clearFiles(errorStore);
}

@Test
Expand Down Expand Up @@ -123,6 +123,19 @@ public void isStartupCrash() throws Exception {
assertFalse(errorStore.isStartupCrash(10000));
}

@Test
public void testFindOldFiles() throws Throwable {
new File(errorStore.oldDirectory, "foo.json").createNewFile();
File dir = errorStore.storageDir;
new File(dir, "foo.json").createNewFile();

List<File> files = errorStore.findStoredFiles();
for (File file : files) {
assertTrue(file.getAbsolutePath().endsWith("foo.json"));
}
assertEquals(2, files.size());
Copy link
Contributor

Choose a reason for hiding this comment

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

If there are multiple clients, will all attempt to find and send files from the "old" directory?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes. I've added a mazerunner scenario which covers this.

}

/**
* Ensures that the file can be serialised back into a JSON report, and contains the same info
* as the original
Expand Down
8 changes: 7 additions & 1 deletion sdk/src/androidTest/java/com/bugsnag/android/FileUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@ final class FileUtils {
private FileUtils() {
}

static void clearFilesInDir(File storageDir) {
static void clearFiles(FileStore fileStore) {
File storageDir = fileStore.storageDir;
clearFilesInDir(storageDir);
clearFilesInDir(new File(fileStore.oldDirectory));
}

private static void clearFilesInDir(File storageDir) {
if (!storageDir.isDirectory()) {
throw new IllegalArgumentException();
}
Expand Down
24 changes: 19 additions & 5 deletions sdk/src/androidTest/java/com/bugsnag/android/SessionStoreTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,14 @@
import org.junit.runner.RunWith;

import java.io.File;
import java.util.List;

@RunWith(AndroidJUnit4.class)
@SmallTest
public class SessionStoreTest {

private File storageDir;
private SessionStore sessionStore;

/**
* Generates a session store with 0 files
Expand All @@ -30,10 +32,9 @@ public class SessionStoreTest {
@Before
public void setUp() throws Exception {
Client client = new Client(InstrumentationRegistry.getContext(), "api-key");
SessionStore sessionStore = client.sessionStore;
assertNotNull(sessionStore.storeDirectory);
storageDir = new File(sessionStore.storeDirectory);
FileUtils.clearFilesInDir(storageDir);
sessionStore = client.sessionStore;
storageDir = sessionStore.storageDir;
FileUtils.clearFiles(sessionStore);
}

/**
Expand All @@ -43,7 +44,7 @@ public void setUp() throws Exception {
*/
@After
public void tearDown() throws Exception {
FileUtils.clearFilesInDir(storageDir);
FileUtils.clearFiles(sessionStore);
}

@Test
Expand All @@ -70,4 +71,17 @@ public void testComparator() throws Exception {
assertTrue(SESSION_COMPARATOR.compare(new File(second), new File(startup)) > 0);
}

@Test
public void testFindOldFiles() throws Throwable {
new File(sessionStore.oldDirectory, "foo.json").createNewFile();
File dir = sessionStore.storageDir;
new File(dir, "foo.json").createNewFile();

List<File> files = sessionStore.findStoredFiles();
for (File file : files) {
assertTrue(file.getAbsolutePath().endsWith("foo.json"));
}
assertEquals(2, files.size());
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ public class SessionTrackingPayloadTest {
private AppData appData;

private SessionStore sessionStore;
private File storageDir;

/**
* Configures a session tracking payload and session store, ensuring that 0 files are present
Expand All @@ -38,9 +37,7 @@ public void setUp() throws Exception {
Context context = InstrumentationRegistry.getContext();
Client client = new Client(context, "api-key");
sessionStore = client.sessionStore;
Assert.assertNotNull(sessionStore.storeDirectory);
storageDir = new File(sessionStore.storeDirectory);
FileUtils.clearFilesInDir(storageDir);
FileUtils.clearFiles(sessionStore);

session = generateSession();
appData = new AppData(context, new Configuration("a"), generateSessionTracker());
Expand All @@ -55,7 +52,7 @@ public void setUp() throws Exception {
*/
@After
public void tearDown() throws Exception {
FileUtils.clearFilesInDir(storageDir);
FileUtils.clearFiles(sessionStore);
}

@Test
Expand Down
10 changes: 5 additions & 5 deletions sdk/src/main/java/com/bugsnag/android/ErrorStore.java
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ public int compare(File lhs, File rhs) {

ErrorStore(@NonNull Configuration config, @NonNull Context appContext) {
super(config, appContext,
"/bugsnag-errors/", 128, ERROR_REPORT_COMPARATOR);
"bugsnag-errors", 128, ERROR_REPORT_COMPARATOR);
}

void flushOnLaunch(final ErrorReportApiClient errorReportApiClient) {
Expand Down Expand Up @@ -88,10 +88,9 @@ public void run() {
* Flush any on-disk errors to Bugsnag
*/
void flushAsync(final ErrorReportApiClient errorReportApiClient) {
if (storeDirectory == null) {
if (storageDir == null) {
return;
}

try {
Async.run(new Runnable() {
@Override
Expand Down Expand Up @@ -162,8 +161,9 @@ private List<File> findLaunchCrashReports() {
String getFilename(Error error) {
boolean isStartupCrash = isStartupCrash(AppData.getDurationMs());
String suffix = isStartupCrash ? STARTUP_CRASH : "";
return String.format(Locale.US, "%s%d_%s%s.json",
storeDirectory, System.currentTimeMillis(), UUID.randomUUID().toString(), suffix);
return String.format(Locale.US, "%s/%d_%s%s.json",
storageDir.getAbsolutePath(), System.currentTimeMillis(),
UUID.randomUUID().toString(), suffix);
}

boolean isStartupCrash(long durationMs) {
Expand Down
67 changes: 49 additions & 18 deletions sdk/src/main/java/com/bugsnag/android/FileStore.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import java.io.File;
import java.io.FileWriter;
import java.io.Writer;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
Expand All @@ -16,8 +17,11 @@ abstract class FileStore<T extends JsonStream.Streamable> {

@NonNull
protected final Configuration config;

@Nullable
final String storeDirectory;
final String oldDirectory;

File storageDir;
Copy link
Contributor

Choose a reason for hiding this comment

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

Why should this property be renamed?

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 wanted it to be clear that there is a difference between the old directory we previously used, and the client-specific directory that we will use in future. Maybe the nomenclature is a bit off here?

private final int maxStoreCount;
private final Comparator<File> comparator;

Expand All @@ -29,31 +33,30 @@ abstract class FileStore<T extends JsonStream.Streamable> {

String path;
try {
path = appContext.getCacheDir().getAbsolutePath() + folder;
File baseDir = new File(appContext.getCacheDir().getAbsolutePath(), folder);
path = baseDir.getAbsolutePath();
storageDir = getStorageDir(path, config);

File outFile = new File(path);
outFile.mkdirs();
if (!outFile.exists()) {
if (!storageDir.exists()) {
Logger.warn("Could not prepare file storage directory");
path = null;
}
} catch (Exception exception) {
Logger.warn("Could not prepare file storage directory", exception);
path = null;
}
this.storeDirectory = path;
this.oldDirectory = path;
}

@Nullable
String write(@NonNull T streamable) {
if (storeDirectory == null) {
if (storageDir == null) {
return null;
}

// Limit number of saved errors to prevent disk space issues
File exceptionDir = new File(storeDirectory);
if (exceptionDir.isDirectory()) {
File[] files = exceptionDir.listFiles();
if (storageDir.isDirectory()) {
File[] files = storageDir.listFiles();
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This needs fixing as it doesn't take the previous directory into account.

if (files != null && files.length >= maxStoreCount) {
// Sort files then delete the first one (oldest timestamp)
Arrays.sort(files, comparator);
Expand Down Expand Up @@ -86,22 +89,50 @@ String write(@NonNull T streamable) {
return null;
}

@NonNull abstract String getFilename(T streamable);
@NonNull
abstract String getFilename(T streamable);

List<File> findStoredFiles() {
List<File> files = new ArrayList<>();

if (storeDirectory != null) {
File dir = new File(storeDirectory);
if (oldDirectory != null) {
File dir = new File(oldDirectory);
addStoredFiles(dir, files);
}
if (storageDir != null) {
addStoredFiles(storageDir, files);
}
return files;
}

if (dir.exists() && dir.isDirectory()) {
File[] values = dir.listFiles();
void addStoredFiles(File dir, List<File> files) {
if (!dir.exists() || !dir.isDirectory()) {
return;
}
File[] values = dir.listFiles();

if (values != null) {
files.addAll(Arrays.asList(values));
if (values != null) {
for (File value : values) {
if (value.isFile()) {
files.add(value);
}
}
}
return files;
}

// support multiple clients in the same app by using a unique directory path

private File getStorageDir(String path, @NonNull Configuration config) {
Copy link
Contributor

Choose a reason for hiding this comment

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

If a project rotates API keys or the on-premise endpoint changes, will stored files ever be sent (or cleared)?

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 currently. Is this something we need to address?

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah - in the worst case, files shouldn't be left on disk indefinitely. In the best case, everything is delivered according to the configuration at the time the report was captured. Splitting directories by endpoint and key works until either component needs to change. A better approach would be to write the entire request to disk including the endpoint and the headers. For example, a (nearly) raw HTTP request (format amended to encode protocol and port):

POST https://notify.bugsnag.com
Bugsnag-Api-Key: aaaabaaaabaaaabaaaa
Content-Type: application/json

{"events": [{"severity": "warning", "exceptions":[]}]

on premise example for contrast:

POST https://bugsnag.example.internal:79002
Bugsnag-Api-Key: aaaabaaaabaaaabaaaa
Content-Type: application/json

{"events": [{"severity": "warning", "exceptions":[]}]

Which could then be parsed until encountering the double newline for the endpoint and headers, then stream the rest as the body directly.

This should:

  • Avoid cycling directories, ensuring everything is delivered and cleared from disk
  • Tie API key to a request even if cached on disk
  • Not affect reports not cached to disk
  • Have the same integration tests
  • Have the same/similar logic for handling reports in the old location

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 think this approach makes sense.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's also worth mentioning that we use the cache directory when storing files on Android, so Files may be deleted if they are left forever and the system is low on disk space.

Reading through the docs it looks like we may want to perform a check on our quota when writing files - I'll create a separate ticket for that.

I think it still makes sense to have a limit of around 128 reports/sessions on disk at any time.

String apiKey = "" + config.getApiKey().hashCode();
String endpoint = "" + config.getEndpoint().hashCode();

File apiDir = new File(path, apiKey);
apiDir.mkdirs();

File dir = new File(apiDir, endpoint);
dir.mkdirs();

return dir;
}

}
2 changes: 1 addition & 1 deletion sdk/src/main/java/com/bugsnag/android/NativeInterface.java
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ public static String getContext() {

@Nullable
public static String getErrorStorePath() {
return getClient().errorStore.storeDirectory;
return getClient().errorStore.storageDir.getAbsolutePath();
}

public static String getUserId() {
Expand Down
6 changes: 3 additions & 3 deletions sdk/src/main/java/com/bugsnag/android/SessionStore.java
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,15 @@ public int compare(File lhs, File rhs) {
};

SessionStore(@NonNull Configuration config, @NonNull Context appContext) {
super(config, appContext, "/bugsnag-sessions/",
super(config, appContext, "bugsnag-sessions",
128, SESSION_COMPARATOR);
}

@NonNull
@Override
String getFilename(Session session) {
return String.format(Locale.US, "%s%s%d.json",
storeDirectory, UUID.randomUUID().toString(), System.currentTimeMillis());
return String.format(Locale.US, "%s/%s%d.json",
storageDir.getAbsolutePath(), UUID.randomUUID().toString(), System.currentTimeMillis());
}

}