Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
Expand Up @@ -11,14 +11,15 @@
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.module.annotations.ReactModule;

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Scanner;

@ReactModule(name = BacktraceFileSystemProvider.NAME)
public class BacktraceFileSystemProvider extends ReactContextBaseJavaModule {
Expand All @@ -42,14 +43,8 @@ public String readFileSync(String path) {
return null;
}

try (Scanner scanner = new Scanner(file)) {
StringBuilder sb = new StringBuilder();

while (scanner.hasNext()) {
sb.append(scanner.nextLine());
}

return sb.toString();
try {
return read(file);
} catch (Exception e) {
Log.d(LOG_TAG, e.getMessage());
return null;
Expand All @@ -63,15 +58,8 @@ public void readFile(String path, Promise promise) {
promise.reject(new FileNotFoundException(path));
return;
}
try (Scanner scanner = new Scanner(file)) {
StringBuilder sb = new StringBuilder();

while (scanner.hasNext()) {
sb.append(scanner.nextLine());
}

scanner.close();
promise.resolve(sb.toString());
try {
promise.resolve(read(file));
} catch (Exception e) {
Log.d(LOG_TAG, e.getMessage());
promise.reject(e);
Expand Down Expand Up @@ -162,4 +150,16 @@ private boolean copy(String sourcePath, String destinationPath) {
return false;
}
}

private String read(File file) throws IOException {
try (FileInputStream inputStream = new FileInputStream(file);
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
byte[] buf = new byte[1024];
int len;
while ((len = inputStream.read(buf)) > 0) {
outputStream.write(buf, 0, len);
}
return new String(outputStream.toByteArray(), StandardCharsets.UTF_8);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package backtrace.library;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;

public class BacktraceFileSystemProviderTest {
private static final String BREADCRUMBS = "{\"id\":1,\"message\":\"café\"}\n{\"id\":2}\n";

@Rule
public TemporaryFolder folder = new TemporaryFolder();

@Test
public void readFileSyncReturnsTheExactContent() throws IOException {
File file = folder.newFile("bt-breadcrumbs-0");
try (FileOutputStream out = new FileOutputStream(file)) {
out.write(BREADCRUMBS.getBytes(StandardCharsets.UTF_8));
}

assertEquals(BREADCRUMBS, new BacktraceFileSystemProvider(null).readFileSync(file.getPath()));
}

@Test
public void readFileSyncReturnsNullForMissingFile() {
String path = new File(folder.getRoot(), "missing").getPath();

assertNull(new BacktraceFileSystemProvider(null).readFileSync(path));
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { type BacktraceFileAttachment as CoreBacktraceFileAttachment } from '@backtrace/sdk-core';
import { Platform } from 'react-native';
import { type FileSystem } from '../storage/';
import { type FileContent } from '../types/FileContent';
import { type FileLocation } from '../types/FileLocation';
export class BacktraceFileAttachment implements CoreBacktraceFileAttachment<FileLocation> {
export class BacktraceFileAttachment implements CoreBacktraceFileAttachment<FileLocation | FileContent> {
public readonly name: string;
public readonly mimeType: string;

private readonly _uploadUri: string;
constructor(
private readonly _fileSystemProvider: FileSystem,
protected readonly _fileSystemProvider: FileSystem,
public readonly filePath: string,
name?: string,
mimeType?: string,
Expand All @@ -18,7 +19,7 @@ export class BacktraceFileAttachment implements CoreBacktraceFileAttachment<File
this._uploadUri = Platform.OS === 'android' ? `file://${this.filePath}` : this.filePath;
}

public get(): FileLocation | undefined {
public get(): FileLocation | FileContent | undefined {
const exists = this._fileSystemProvider.existsSync(this.filePath);

if (!exists) {
Expand Down
64 changes: 64 additions & 0 deletions packages/react-native/src/attachment/FileSnapshotAttachment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { Platform } from 'react-native';
import { type FileSystem } from '../storage/';
import { type FileContent } from '../types/FileContent';
import { type FileLocation } from '../types/FileLocation';
import { BacktraceFileAttachment } from './BacktraceFileAttachment';

export interface FileSnapshotOptions {
readonly queuedLines?: () => readonly string[];
readonly maximumLines?: number;
readonly maximumLength?: number;
}

// React Native on Android sizes a file part before sending it. A write in between aborts the upload.
export class FileSnapshotAttachment extends BacktraceFileAttachment {
constructor(
fileSystemProvider: FileSystem,
filePath: string,
name?: string,
mimeType?: string,
private readonly _options: FileSnapshotOptions = {},
) {
super(fileSystemProvider, filePath, name, mimeType);
}

public get(): FileLocation | FileContent | undefined {
if (Platform.OS !== 'android') {
return super.get();
}

const content = this._fileSystemProvider.readFileSync(this.filePath) || '';
const completeLines = content.substring(0, content.lastIndexOf('\n') + 1);
const snapshot = this.newestLines(completeLines + this.linesNotInFile(completeLines));
if (!snapshot) {
return undefined;
}

return {
string: snapshot,
name: this.name,
type: this.mimeType,
};
}

private linesNotInFile(completeLines: string): string {
const queued = this._options.queuedLines?.() ?? [];
const lastLine = completeLines.substring(completeLines.lastIndexOf('\n', completeLines.length - 2) + 1);
// Queued lines up to the file's last line have already reached the disk.
return queued.slice(queued.indexOf(lastLine) + 1).join('');
}

private newestLines(snapshot: string): string {
const { maximumLines = Infinity, maximumLength = Infinity } = this._options;
const kept: string[] = [];
let length = 0;
for (const line of (snapshot.match(/[^\n]*\n/g) ?? []).reverse()) {
if (kept.length >= maximumLines || length + line.length > maximumLength) {
break;
}
kept.push(line);
length += line.length;
}
return kept.reverse().join('');
}
}
54 changes: 41 additions & 13 deletions packages/react-native/src/breadcrumbs/FileBreadcrumbsStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
} from '@backtrace/sdk-core';
import { WritableStream } from 'web-streams-polyfill';
import { BacktraceFileAttachment } from '../attachment/BacktraceFileAttachment';
import { FileSnapshotAttachment } from '../attachment/FileSnapshotAttachment';
import { type FileSystem } from '../storage';
import { ChunkifierSink, type ChunkSplitterFactory } from '../storage/Chunkifier';
import { combinedChunkSplitter } from '../storage/combinedChunkSplitter';
Expand All @@ -31,29 +32,38 @@ export class FileBreadcrumbsStorage implements BreadcrumbsStorage {
private readonly _destinationStream: WritableStream;
private readonly _destinationWriter: WritableStreamDefaultWriter;
private readonly _sink: FileChunkSink;
private readonly _queuedLines = new Map<number, string>();
private readonly _firstFile: string;
private readonly _fileLimits: { maximumLines?: number; maximumLength?: number };

constructor(
session: SessionFiles,
private readonly _fileSystem: FileSystem,
private readonly _limits: BreadcrumbsStorageLimits,
onFilesChange?: (lastBreadcrumbId: number) => void,
) {
const file = (n: number) => session.getFileName(FileBreadcrumbsStorage.getFileName(n));
this._firstFile = file(0);
this._sink = new FileChunkSink({
maxFiles: 2,
fs: this._fileSystem,
file: (n) => session.getFileName(FileBreadcrumbsStorage.getFileName(n)),
file,
onFilesChange: onFilesChange && (() => onFilesChange(this._lastBreadcrumbId)),
});

const { maximumBreadcrumbs, maximumTotalBreadcrumbsSize } = this._limits;
const maximumLines = maximumBreadcrumbs !== undefined ? Math.ceil(maximumBreadcrumbs / 2) : undefined;
const maximumLength =
maximumTotalBreadcrumbsSize !== undefined ? Math.ceil(maximumTotalBreadcrumbsSize / 2) : undefined;
this._fileLimits = { maximumLines, maximumLength };

const splitters: ChunkSplitterFactory<string>[] = [];
const maximumBreadcrumbs = this._limits.maximumBreadcrumbs;
if (maximumBreadcrumbs !== undefined) {
splitters.push(() => lineChunkSplitter(Math.ceil(maximumBreadcrumbs / 2)));
if (maximumLines !== undefined) {
splitters.push(() => lineChunkSplitter(maximumLines));
}

const maximumTotalBreadcrumbsSize = this._limits.maximumTotalBreadcrumbsSize;
if (maximumTotalBreadcrumbsSize !== undefined) {
splitters.push(() => lengthChunkSplitter(Math.ceil(maximumTotalBreadcrumbsSize / 2), 'skip'));
if (maximumLength !== undefined) {
splitters.push(() => lengthChunkSplitter(maximumLength, 'skip'));
}

if (!splitters[0]) {
Expand Down Expand Up @@ -83,9 +93,16 @@ export class FileBreadcrumbsStorage implements BreadcrumbsStorage {
}

public getAttachments(): BacktraceFileAttachment[] {
const files = [...this._sink.files].map((f) => f.path);
const files =
this._sink.files.length || !this._queuedLines.size
? this._sink.files.map((f) => f.path)
: [this._firstFile];
return files.map(
(f, i) => new BacktraceFileAttachment(this._fileSystem, f, `bt-breadcrumbs-${i}`, 'application/json'),
(f, i) =>
new FileSnapshotAttachment(this._fileSystem, f, `bt-breadcrumbs-${i}`, 'application/json', {
...this._fileLimits,
queuedLines: () => (this.newestFile() === f ? [...this._queuedLines.values()] : []),
}),
);
}

Expand Down Expand Up @@ -120,14 +137,25 @@ export class FileBreadcrumbsStorage implements BreadcrumbsStorage {
attributes: rawBreadcrumb.attributes,
};

const breadcrumbJson = JSON.stringify(breadcrumb, jsonEscaper());
this._destinationWriter.write(breadcrumbJson + '\n').catch(() => {
// Fail silently here, there's not much we can do about this
});
const line = JSON.stringify(breadcrumb, jsonEscaper()) + '\n';
const { maximumLength } = this._fileLimits;
if (maximumLength === undefined || line.length <= maximumLength) {
this._queuedLines.set(id, line);
}
this._destinationWriter
.write(line)
.catch(() => {
// Fail silently here, there's not much we can do about this
})
.finally(() => this._queuedLines.delete(id));

return id;
}

private newestFile() {
return this._sink.files[this._sink.files.length - 1]?.path ?? this._firstFile;
}

private static getFileName(index: number) {
return `${FILE_PREFIX}-${index}`;
}
Expand Down
5 changes: 5 additions & 0 deletions packages/react-native/src/types/FileContent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export interface FileContent {
string: string;
name: string;
type: string;
}
Loading
Loading