Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ example/*/.env
Pods
Build
xcuserdata
/Package.resolved

# macOS files
.DS_Store
Expand Down
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,29 @@

## Unreleased

### Features

- Add `onNativeLog` callback to intercept and forward native SDK logs to JavaScript console ([#1249](https://github.com/getsentry/sentry-capacitor/pull/1249/))
- The callback receives native log events with `level`, `component`, and `message` properties
- Only works when `debug: true` is enabled in `Sentry.init`
- Use `consoleSandbox` inside the callback to prevent feedback loops with Sentry's console integration

```js
import * as Sentry from '@sentry/capacitor';

Sentry.init({
debug: true,
onNativeLog: ({ level, component, message }) => {
// Use consoleSandbox to avoid feedback loops
Sentry.consoleSandbox(() => {
console.log(
`[Sentry Native] [${level.toUpperCase()}] [${component}] ${message}`,
);
});
},
});
```

### Dependencies

- Bump Android SDK from v8.35.0 to v8.41.0 ([#1247](https://github.com/getsentry/sentry-capacitor/pull/1247))
Expand Down
80 changes: 80 additions & 0 deletions android/src/main/java/io/sentry/capacitor/CapSentryLogger.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package io.sentry.capacitor;

import com.getcapacitor.JSObject;
import io.sentry.ILogger;
import io.sentry.SentryLevel;
import io.sentry.android.core.AndroidLogger;

/**
* Custom ILogger implementation that wraps AndroidLogger and forwards log messages to JS.
* This allows native SDK logs to appear in the browser console when debug mode is enabled.
*/
public class CapSentryLogger implements ILogger {
private static final String TAG = "CapacitorSentry";

public interface NativeLogEmitter {
void emit(JSObject data);
}

private final AndroidLogger androidLogger;
private volatile NativeLogEmitter emitter;

public CapSentryLogger() {
this.androidLogger = new AndroidLogger(TAG);
}

public void setEmitter(NativeLogEmitter emitter) {
this.emitter = emitter;
}

@Override
public void log(SentryLevel level, String message, Object... args) {
androidLogger.log(level, message, args);

String formattedMessage =
(args == null || args.length == 0) ? message : String.format(message, args);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Unprotected String.format can throw uncaught exception

Medium Severity

The String.format(message, args) calls in the log methods are not wrapped in try-catch. The underlying AndroidLogger.log() internally catches IllegalFormatException from malformed format strings (this was a known crash, sentry-java#1985), but the subsequent String.format call in CapSentryLogger lacks this same protection. If format specifiers in message don't match args, an IllegalFormatException will propagate uncaught into the SDK internals.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 19cae50. Configure here.

forwardToJS(level, formattedMessage);
}

@Override
public void log(SentryLevel level, String message, Throwable throwable) {
androidLogger.log(level, message, throwable);

String fullMessage = throwable != null ? message + ": " + throwable.getMessage() : message;
forwardToJS(level, fullMessage);
}

@Override
public void log(SentryLevel level, Throwable throwable, String message, Object... args) {
androidLogger.log(level, throwable, message, args);

String formattedMessage =
(args == null || args.length == 0) ? message : String.format(message, args);
if (throwable != null) {
formattedMessage += ": " + throwable.getMessage();
}
forwardToJS(level, formattedMessage);
}

@Override
public boolean isEnabled(SentryLevel level) {
return androidLogger.isEnabled(level);
}

private void forwardToJS(SentryLevel level, String message) {
NativeLogEmitter currentEmitter = emitter;
if (currentEmitter == null) {
return;
}

try {
JSObject data = new JSObject();
data.put("level", level.name().toLowerCase());
data.put("component", "Sentry");
data.put("message", message);
currentEmitter.emit(data);
} catch (Exception e) {
androidLogger.log(SentryLevel.DEBUG, "Failed to forward log to JS: " + e.getMessage());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,15 @@ public class SentryCapacitor extends Plugin {
private static final String NATIVE_SDK_NAME = "sentry.native.android.capacitor";
private static final String ANDROID_SDK_NAME = "sentry.java.android.capacitor";

private final CapSentryLogger capSentryLogger = new CapSentryLogger();
static final ILogger logger = new AndroidLogger("capacitor-sentry");
private Context context;
private static PackageInfo packageInfo;

@Override
public void load() {
super.load();
capSentryLogger.setEmitter(data -> notifyListeners("SentryNativeLog", data));

if (this.context == null) {
this.context = this.bridge.getContext();
Expand All @@ -74,6 +76,8 @@ public void initNativeSdk(final PluginCall call) {
SentryAndroid.init(
this.getContext(),
options -> {
options.setLogger(capSentryLogger);

SdkVersion sdkVersion = options.getSdkVersion();
if (sdkVersion == null) {
sdkVersion = new SdkVersion(ANDROID_SDK_NAME, BuildConfig.VERSION_NAME);
Expand Down

This file was deleted.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import Foundation
import Capacitor
@_spi(Private) @preconcurrency import Sentry

private let nativeLogEventName = "SentryNativeLog"

/**
* Singleton class that forwards native Sentry SDK logs to JavaScript via Capacitor events.
* This allows developers to see native SDK logs in the browser console when debug mode is enabled.
*
* Note: iOS log forwarding requires sentry-cocoa to expose `SentrySDKLog.setOutput`.
* See: https://github.com/getsentry/sentry-cocoa/pull/7444
*/
final class SentryCapacitorNativeLogsForwarder: @unchecked Sendable {
static let shared = SentryCapacitorNativeLogsForwarder()

private weak var plugin: CAPPlugin?

private init() {}

func configure(plugin: CAPPlugin) {
self.plugin = plugin

SentrySDKLog.setOutput { [weak self] message in
// Always print to console (default behavior)
NSLog("%@", message)

guard let self = self else { return }
self.forwardLogMessage(message)
}
}

func stopForwarding() {
self.plugin = nil
SentrySDKLog.setOutput { message in NSLog("%@", message) }
}

private func forwardLogMessage(_ message: String) {
guard let plugin = self.plugin else { return }
guard message.hasPrefix("[Sentry]") else { return }

let level = extractLevel(from: message)
let component = extractComponent(from: message)
let cleanMessage = extractCleanMessage(from: message)

let body: [String: Any] = [
"level": level,
"component": component,
"message": cleanMessage,
]

DispatchQueue.main.async { [weak plugin] in
plugin?.notifyListeners(nativeLogEventName, data: body)
}
}

private func extractLevel(from message: String) -> String {
let pattern = "\\[(debug|info|warning|error|fatal)\\]"
guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive),
let match = regex.firstMatch(in: message, range: NSRange(message.startIndex..., in: message)),
match.numberOfRanges > 1,
let range = Range(match.range(at: 1), in: message)
else {
return "info"
}
return String(message[range]).lowercased()
}

private func extractComponent(from message: String) -> String {
let pattern = "\\[([A-Za-z]+):\\d+\\]"
guard let regex = try? NSRegularExpression(pattern: pattern),
let match = regex.firstMatch(in: message, range: NSRange(message.startIndex..., in: message)),
match.numberOfRanges > 1,
let range = Range(match.range(at: 1), in: message)
else {
return "Sentry"
}
return String(message[range])
}

private func extractCleanMessage(from message: String) -> String {
let pattern = "^\\[Sentry\\]\\s*\\[[^\\]]+\\]\\s*\\[[^\\]]+\\]\\s*(?:\\[[^\\]]+\\]\\s*)?"
guard let regex = try? NSRegularExpression(pattern: pattern) else {
return message
}
let result = regex.stringByReplacingMatches(
in: message,
range: NSRange(message.startIndex..., in: message),
withTemplate: ""
)
return result.trimmingCharacters(in: .whitespaces)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ public class SentryCapacitorPlugin: CAPPlugin, CAPBridgedPlugin {
SentrySDK.start(options: options)
}

if options.debug {
SentryCapacitorNativeLogsForwarder.shared.configure(plugin: self)
}

sentryOptions = options

// checking enableAutoSessionTracking is actually not necessary, but we'd spare the sent bits.
Expand Down Expand Up @@ -456,6 +460,7 @@ public class SentryCapacitorPlugin: CAPPlugin, CAPBridgedPlugin {
}

@objc func closeNativeSdk(_ call: CAPPluginCall ) {
SentryCapacitorNativeLogsForwarder.shared.stopForwarding()
SentrySDK.close()
call.resolve()
}
Expand Down
90 changes: 90 additions & 0 deletions src/NativeLogListener.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import type { PluginListenerHandle } from '@capacitor/core';
import { Capacitor } from '@capacitor/core';
import { debug } from '@sentry/core';
import type { NativeLogEntry } from './options';
import { SentryCapacitor } from './plugin';

const NATIVE_LOG_EVENT_NAME = 'SentryNativeLog';

let _removeListener: (() => void) | undefined;

async function registerNativeListener(
callback: (log: NativeLogEntry) => void,
setHandle: (listener: PluginListenerHandle) => void,
isRemoved: () => boolean,
): Promise<void> {
const listenerHandle = await SentryCapacitor.addListener(NATIVE_LOG_EVENT_NAME, callback);
if (isRemoved()) {
await listenerHandle.remove();
} else {
setHandle(listenerHandle);
debug.log('Native log listener set up successfully.');
}
}

/**
* Sets up the native log listener that forwards logs from the native SDK to JS.
* This only works when `debug: true` is set in Sentry options.
*
* @param callback - The callback to invoke when a native log is received.
*/
export function setupNativeLogListener(callback: (log: NativeLogEntry) => void): void {
if (_removeListener) {
return;
}

const platform = Capacitor.getPlatform();
if (platform !== 'ios' && platform !== 'android') {
debug.log('Native log listener is only supported on iOS and Android.');
return;
}

let listenerHandle: PluginListenerHandle | undefined;
let removed = false;

_removeListener = () => {
removed = true;
_removeListener = undefined;
void listenerHandle?.remove();
};

registerNativeListener(
callback,
listener => { listenerHandle = listener; },
() => removed,
).catch((error: unknown) => {
debug.warn('Failed to set up native log listener:', error);
_removeListener = undefined;
});
}

/**
* Removes the native log listener previously set up by `setupNativeLogListener`.
*/
export function stopNativeLogListener(): void {
_removeListener?.();
}

/**
* Default handler for native logs that uses Sentry's debug logger.
* This avoids interference with captureConsoleIntegration which would
* otherwise capture these logs as breadcrumbs or events.
*/
export function defaultNativeLogHandler(log: NativeLogEntry): void {
const message = `[Native] [${log.component}] ${log.message}`;

switch (log.level.toLowerCase()) {
case 'fatal':
case 'error':
debug.error(message);
break;
case 'warning':
debug.warn(message);
break;
case 'info':
case 'debug':
default:
debug.log(message);
break;
}
}
Loading
Loading