diff --git a/packages/core/android/libs/replay-stubs.jar b/packages/core/android/libs/replay-stubs.jar index d9eb509a5c..c0d6e23a88 100644 Binary files a/packages/core/android/libs/replay-stubs.jar and b/packages/core/android/libs/replay-stubs.jar differ diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java index 4a37c28827..eacddaa940 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java @@ -31,6 +31,7 @@ import io.sentry.Breadcrumb; import io.sentry.ILogger; import io.sentry.IScope; +import io.sentry.ISentryClient; import io.sentry.ISentryExecutorService; import io.sentry.ISerializer; import io.sentry.ScopesAdapter; @@ -55,6 +56,10 @@ import io.sentry.protocol.SentryId; import io.sentry.protocol.User; import io.sentry.protocol.ViewHierarchy; +import io.sentry.SentryLogEvent; +import io.sentry.SentryLogEventAttributeValue; +import io.sentry.SentryLogLevel; +import io.sentry.SpanId; import io.sentry.util.DebugMetaPropertiesApplier; import io.sentry.util.FileUtils; import io.sentry.util.JsonSerializationUtils; @@ -350,6 +355,136 @@ public void captureEnvelope(String rawBytes, ReadableMap options, Promise promis promise.resolve(true); } + public void captureLog(ReadableMap log) { + if (log == null) { + return; + } + + try { + String levelStr = log.hasKey("level") ? log.getString("level") : "info"; + String body = log.hasKey("body") ? log.getString("body") : null; + + if (body == null) { + return; + } + + // Parse log level + SentryLogLevel level; + switch (levelStr != null ? levelStr : "info") { + case "trace": + level = SentryLogLevel.TRACE; + break; + case "debug": + level = SentryLogLevel.DEBUG; + break; + case "warn": + level = SentryLogLevel.WARN; + break; + case "error": + level = SentryLogLevel.ERROR; + break; + case "fatal": + level = SentryLogLevel.FATAL; + break; + case "info": + default: + level = SentryLogLevel.INFO; + break; + } + + // Parse traceId + String traceIdStr = log.hasKey("traceId") ? log.getString("traceId") : null; + SentryId traceId = + traceIdStr != null && !traceIdStr.isEmpty() + ? new SentryId(traceIdStr) + : SentryId.EMPTY_ID; + + // Parse timestamp (in seconds) + Double timestamp = + log.hasKey("timestamp") ? log.getDouble("timestamp") : System.currentTimeMillis() / 1000.0; + + // Create the log event + SentryLogEvent logEvent = new SentryLogEvent(traceId, timestamp, body, level); + + // Set spanId if provided + if (log.hasKey("spanId")) { + String spanIdStr = log.getString("spanId"); + if (spanIdStr != null && !spanIdStr.isEmpty()) { + logEvent.setSpanId(new SpanId(spanIdStr)); + } + } + + // Set severity number if provided + if (log.hasKey("severityNumber")) { + logEvent.setSeverityNumber((int) log.getDouble("severityNumber")); + } + + // Parse and set attributes + if (log.hasKey("attributes")) { + ReadableMap jsAttributes = log.getMap("attributes"); + if (jsAttributes != null) { + Map attributes = new HashMap<>(); + + ReadableMapKeySetIterator iterator = jsAttributes.keySetIterator(); + while (iterator.hasNextKey()) { + String key = iterator.nextKey(); + ReadableMap attrValue = jsAttributes.getMap(key); + if (attrValue != null && attrValue.hasKey("value")) { + String type = attrValue.hasKey("type") ? attrValue.getString("type") : "string"; + SentryLogEventAttributeValue attributeValue = null; + + switch (type != null ? type : "string") { + case "boolean": + attributeValue = + new SentryLogEventAttributeValue("boolean", attrValue.getBoolean("value")); + break; + case "integer": + attributeValue = + new SentryLogEventAttributeValue("integer", (int) attrValue.getDouble("value")); + break; + case "double": + attributeValue = + new SentryLogEventAttributeValue("double", attrValue.getDouble("value")); + break; + case "string": + default: + attributeValue = + new SentryLogEventAttributeValue("string", attrValue.getString("value")); + break; + } + + if (attributeValue != null) { + attributes.put(key, attributeValue); + } + } + } + + // Add origin attribute to indicate this log came from JavaScript + attributes.put( + "sentry.origin", new SentryLogEventAttributeValue("string", "react-native.js")); + + logEvent.setAttributes(attributes); + } + } else { + // Still add the origin attribute even if no other attributes + Map attributes = new HashMap<>(); + attributes.put( + "sentry.origin", new SentryLogEventAttributeValue("string", "react-native.js")); + logEvent.setAttributes(attributes); + } + + // Capture the log through the client + Sentry.configureScope( + scope -> { + final ISentryClient client = scope.getClient(); + client.captureLog(logEvent, scope); + }); + + } catch (Throwable e) { // NOPMD - We don't want to crash in any case + logger.log(SentryLevel.ERROR, "Error while capturing log", e); + } + } + public void captureScreenshot(Promise promise) { final Activity activity = getCurrentActivity(); diff --git a/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java b/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java index b928d2d9c4..041ecb7d52 100644 --- a/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java +++ b/packages/core/android/src/newarch/java/io/sentry/react/RNSentryModule.java @@ -203,6 +203,11 @@ public void encodeToBase64(ReadableArray array, Promise promise) { this.impl.encodeToBase64(array, promise); } + @Override + public void captureLog(ReadableMap log) { + this.impl.captureLog(log); + } + @Override public void popTimeToDisplayFor(String key, Promise promise) { this.impl.popTimeToDisplayFor(key, promise); diff --git a/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java b/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java index 0488e143c9..6577c38581 100644 --- a/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java +++ b/packages/core/android/src/oldarch/java/io/sentry/react/RNSentryModule.java @@ -203,6 +203,11 @@ public void encodeToBase64(ReadableArray array, Promise promise) { this.impl.encodeToBase64(array, promise); } + @ReactMethod + public void captureLog(ReadableMap log) { + this.impl.captureLog(log); + } + @ReactMethod public void popTimeToDisplayFor(String key, Promise promise) { this.impl.popTimeToDisplayFor(key, promise); diff --git a/packages/core/ios/RNSentry.mm b/packages/core/ios/RNSentry.mm index ec050bc56f..417565119a 100644 --- a/packages/core/ios/RNSentry.mm +++ b/packages/core/ios/RNSentry.mm @@ -27,6 +27,10 @@ #import #import +#if __has_include() +# import +#endif + // This guard prevents importing Hermes in JSC apps #if SENTRY_PROFILING_ENABLED # import @@ -978,4 +982,93 @@ + (SentryUser *_Nullable)userFrom:(NSDictionary *)userKeys resolve(base64String); } +RCT_EXPORT_METHOD(captureLog : (NSDictionary *)log) +{ +#if __has_include() + NSString *levelStr = log[@"level"]; + NSString *body = log[@"body"]; + + if (body == nil) { + return; + } + + SentryLogLevel level = SentryLogLevelInfo; + if ([levelStr isEqualToString:@"trace"]) { + level = SentryLogLevelTrace; + } else if ([levelStr isEqualToString:@"debug"]) { + level = SentryLogLevelDebug; + } else if ([levelStr isEqualToString:@"info"]) { + level = SentryLogLevelInfo; + } else if ([levelStr isEqualToString:@"warn"]) { + level = SentryLogLevelWarning; + } else if ([levelStr isEqualToString:@"error"]) { + level = SentryLogLevelError; + } else if ([levelStr isEqualToString:@"fatal"]) { + level = SentryLogLevelFatal; + } + + // Convert attributes from JS format to SentryAttribute dictionary + NSMutableDictionary *attributes = [NSMutableDictionary new]; + NSDictionary *jsAttributes = log[@"attributes"]; + if ([jsAttributes isKindOfClass:[NSDictionary class]]) { + for (NSString *key in jsAttributes) { + NSDictionary *attrValue = jsAttributes[key]; + if ([attrValue isKindOfClass:[NSDictionary class]]) { + id value = attrValue[@"value"]; + if (value != nil) { + if ([value isKindOfClass:[NSString class]]) { + attributes[key] = [[SentryAttribute alloc] initWithValue:value]; + } else if ([value isKindOfClass:[NSNumber class]]) { + // Check if it's a boolean + if (strcmp([value objCType], @encode(BOOL)) == 0) { + attributes[key] = [[SentryAttribute alloc] initWithValue:value]; + } else { + attributes[key] = [[SentryAttribute alloc] initWithValue:value]; + } + } + } + } + } + } + + // Add origin attribute to indicate this log came from JavaScript + attributes[@"sentry.origin"] = [[SentryAttribute alloc] initWithValue:@"react-native.js"]; + + // Create SentryLog with level, body, and attributes + SentryLog *sentryLog = [[SentryLog alloc] initWithLevel:level body:body attributes:attributes]; + + // Set timestamp if provided (convert from seconds to Date) + NSNumber *timestamp = log[@"timestamp"]; + if ([timestamp isKindOfClass:[NSNumber class]]) { + sentryLog.timestamp = [NSDate dateWithTimeIntervalSince1970:[timestamp doubleValue]]; + } + + // Set traceId if provided + NSString *traceId = log[@"traceId"]; + if ([traceId isKindOfClass:[NSString class]] && traceId.length > 0) { + sentryLog.traceId = [[SentryId alloc] initWithUUIDString:traceId]; + } + + // Set spanId if provided + NSString *spanId = log[@"spanId"]; + if ([spanId isKindOfClass:[NSString class]] && spanId.length > 0) { + sentryLog.spanId = [[SpanId alloc] initWithValue:spanId]; + } + + // Set severityNumber if provided + NSNumber *severityNumber = log[@"severityNumber"]; + if ([severityNumber isKindOfClass:[NSNumber class]]) { + sentryLog.severityNumber = severityNumber; + } + + // Capture the log through the SDK + [SentrySDKWrapper configureScope:^(SentryScope *_Nonnull scope) { + SentryClient *client = [PrivateSentrySDKOnly getCurrentHub].client; + if (client != nil) { + [client captureLog:sentryLog withScope:scope]; + } + }]; +#endif +} + @end diff --git a/packages/core/src/js/NativeRNSentry.ts b/packages/core/src/js/NativeRNSentry.ts index afd8fba03d..ae168cbaba 100644 --- a/packages/core/src/js/NativeRNSentry.ts +++ b/packages/core/src/js/NativeRNSentry.ts @@ -54,6 +54,7 @@ export interface Spec extends TurboModule { popTimeToDisplayFor(key: string): Promise; setActiveSpanId(spanId: string): boolean; encodeToBase64(data: number[]): Promise; + captureLog(log: UnsafeObject): void; } export type NativeStackFrame = { @@ -159,5 +160,26 @@ export type NativeScreenshot = { filename: string; }; +/** + * Log event structure for forwarding JS logs to native SDK. + * The native SDK will batch and handle lifecycle events (background/termination). + */ +export type NativeLogEvent = { + /** Timestamp in seconds since epoch */ + timestamp: number; + /** Log level: trace, debug, info, warn, error, fatal */ + level: string; + /** The log message body */ + body: string; + /** Trace ID to associate this log with distributed tracing */ + traceId: string; + /** Span ID of the active span when the log was captured */ + spanId?: string; + /** Numeric severity level */ + severityNumber?: number; + /** Structured attributes attached to the log */ + attributes?: Record; +}; + // The export must be here to pass codegen even if not used export default TurboModuleRegistry.getEnforcing('RNSentry'); diff --git a/packages/core/src/js/client.ts b/packages/core/src/js/client.ts index 74a834090a..9b058205c2 100644 --- a/packages/core/src/js/client.ts +++ b/packages/core/src/js/client.ts @@ -5,6 +5,7 @@ import type { Envelope, Event, EventHint, + Log, Outcome, SeverityLevel, TransportMakeRequestResponse, @@ -22,6 +23,7 @@ import { Alert } from 'react-native'; import { getDevServer } from './integrations/debugsymbolicatorutils'; import { defaultSdkInfo } from './integrations/sdkinfo'; import { getDefaultSidecarUrl } from './integrations/spotlight'; +import type { NativeLogEvent } from './NativeRNSentry'; import type { ReactNativeClientOptions } from './options'; import type { mobileReplayIntegration } from './replay/mobilereplay'; import { MOBILE_REPLAY_INTEGRATION_NAME } from './replay/mobilereplay'; @@ -31,8 +33,6 @@ import { mergeOutcomes } from './utils/outcome'; import { ReactNativeLibraries } from './utils/rnlibraries'; import { NATIVE } from './wrapper'; -const DEFAULT_FLUSH_INTERVAL = 5000; - /** * The Sentry React Native SDK Client. * @@ -41,7 +41,6 @@ const DEFAULT_FLUSH_INTERVAL = 5000; */ export class ReactNativeClient extends Client { private _outcomesBuffer: Outcome[]; - private _logFlushIdleTimeout: ReturnType | undefined; /** * Creates a new React Native SDK instance. @@ -82,18 +81,15 @@ export class ReactNativeClient extends Client { } if (options.enableLogs) { - this.on('flush', () => { - _INTERNAL_flushLogsBuffer(this); + // Forward logs to native SDK for batching and lifecycle-aware flushing. + // Native SDKs handle flushing on background/termination to minimize data loss. + this.on('afterCaptureLog', (log: Log) => { + this._forwardLogToNative(log); }); - this.on('afterCaptureLog', () => { - if (this._logFlushIdleTimeout) { - clearTimeout(this._logFlushIdleTimeout); - } - - this._logFlushIdleTimeout = setTimeout(() => { - _INTERNAL_flushLogsBuffer(this); - }, DEFAULT_FLUSH_INTERVAL); + // Keep flush event handler as a fallback for explicit flush() calls + this.on('flush', () => { + _INTERNAL_flushLogsBuffer(this); }); } @@ -273,4 +269,71 @@ export class ReactNativeClient extends Client { envelope[items].push(clientReportItem); } } + + /** + * Forwards a log to the native SDK for batching and lifecycle-aware flushing. + * Native SDKs handle flushing on background/termination to minimize data loss. + */ + private _forwardLogToNative(log: Log): void { + if (!NATIVE.enableNative) { + return; + } + + try { + // Extract message string from ParameterizedString + const messageStr = typeof log.message === 'string' ? log.message : log.message?.[0] || ''; + + const nativeLog: NativeLogEvent = { + timestamp: Date.now() / 1000, + level: log.level || 'info', + body: messageStr, + // traceId will be set by native SDK from current scope + traceId: '', + severityNumber: log.severityNumber, + attributes: this._convertLogAttributes(log.attributes), + }; + + NATIVE.captureLog(nativeLog); + } catch (e) { + debug.error('[ReactNativeClient] Failed to forward log to native:', e); + } + } + + /** + * Converts log attributes to the format expected by the native SDK. + */ + private _convertLogAttributes( + attributes: Record | undefined, + ): Record | undefined { + if (!attributes) { + return undefined; + } + + const result: Record = {}; + + for (const key in attributes) { + if (!Object.prototype.hasOwnProperty.call(attributes, key)) { + continue; + } + + const value = attributes[key]; + + if (value === null || value === undefined) { + continue; + } + + let type: string; + if (typeof value === 'boolean') { + type = 'boolean'; + } else if (typeof value === 'number') { + type = Number.isInteger(value) ? 'integer' : 'double'; + } else { + type = 'string'; + } + + result[key] = { type, value }; + } + + return Object.keys(result).length > 0 ? result : undefined; + } } diff --git a/packages/core/src/js/wrapper.ts b/packages/core/src/js/wrapper.ts index 35d686e39d..ee2ab5d376 100644 --- a/packages/core/src/js/wrapper.ts +++ b/packages/core/src/js/wrapper.ts @@ -17,6 +17,7 @@ import type { NativeAppStartResponse, NativeDeviceContextsResponse, NativeFramesResponse, + NativeLogEvent, NativeReleaseResponse, NativeScreenshot, NativeStackFrames, @@ -138,6 +139,12 @@ interface SentryNativeWrapper { encodeToBase64(data: Uint8Array): Promise; + /** + * Forwards a log event to the native SDK for batching and sending. + * The native SDK handles lifecycle events (background/termination) to minimize data loss. + */ + captureLog(log: NativeLogEvent): void; + primitiveProcessor(value: Primitive): string; } @@ -863,6 +870,25 @@ export const NATIVE: SentryNativeWrapper = { } }, + /** + * Forwards a log event to the native SDK for batching and sending. + * The native SDK handles lifecycle events (background/termination) to minimize data loss. + */ + captureLog(log: NativeLogEvent): void { + if (!this.enableNative) { + return; + } + if (!this._isModuleLoaded(RNSentry)) { + return; + } + + try { + RNSentry.captureLog(log); + } catch (error) { + debug.error('[NATIVE] Failed to capture log:', error); + } + }, + primitiveProcessor: function (value: Primitive): string { return value as string; }, diff --git a/packages/core/test/client.test.ts b/packages/core/test/client.test.ts index 9805fba27c..ea162931d9 100644 --- a/packages/core/test/client.test.ts +++ b/packages/core/test/client.test.ts @@ -885,14 +885,30 @@ describe('Tests ReactNativeClient', () => { expect(flushLogsSpy).not.toHaveBeenCalled(); }); - it.each([['all' as const], ['js' as const]])('flushes logs when logsOrigin is %s', logOrlogsOriginigin => { - jest.useFakeTimers(); + it.each([['all' as const], ['js' as const]])('forwards logs to native when logsOrigin is %s', logsOrigin => { + const captureLogSpy = jest.spyOn(NATIVE, 'captureLog').mockImplementation(jest.fn()); + + const { client } = createClientWithSpy({ enableLogs: true, logsOrigin }); + + client.emit('afterCaptureLog', { message: 'test', level: 'info', attributes: {} } as unknown); + + expect(captureLogSpy).toHaveBeenCalledTimes(1); + expect(captureLogSpy).toHaveBeenCalledWith( + expect.objectContaining({ + body: 'test', + level: 'info', + }), + ); + + captureLogSpy.mockRestore(); + }); + + it('flushes logs on explicit flush event', () => { const flushLogsSpy = jest.spyOn(SentryCore, '_INTERNAL_flushLogsBuffer').mockImplementation(jest.fn()); - const { client } = createClientWithSpy({ enableLogs: true, logsOrigin: logOrlogsOriginigin }); + const { client } = createClientWithSpy({ enableLogs: true, logsOrigin: 'js' }); - client.emit('afterCaptureLog', { message: 'test', attributes: {} } as unknown); - jest.advanceTimersByTime(5000); + client.emit('flush'); expect(flushLogsSpy).toHaveBeenCalledTimes(1); expect(flushLogsSpy).toHaveBeenLastCalledWith(client); diff --git a/samples/react-native/src/Screens/ErrorsScreen.tsx b/samples/react-native/src/Screens/ErrorsScreen.tsx index d1b2052ed1..53171b0246 100644 --- a/samples/react-native/src/Screens/ErrorsScreen.tsx +++ b/samples/react-native/src/Screens/ErrorsScreen.tsx @@ -22,7 +22,8 @@ import { setScopeProperties } from '../setScopeProperties'; import { TimeToFullDisplay } from '../utils'; import type { Event as SentryEvent } from '@sentry/core'; -const { AssetsModule, CppModule, CrashModule, TestControlModule } = NativeModules; +const { AssetsModule, CppModule, CrashModule, TestControlModule } = + NativeModules; interface Props { navigation: StackNavigationProp; @@ -129,7 +130,9 @@ const ErrorsScreen = (_props: Props) => {