diff --git a/packages/core/android/src/main/java/io/embrace/embracewrapper/EmbraceManagerModule.java b/packages/core/android/src/main/java/io/embrace/embracewrapper/EmbraceManagerModule.java index 505c0bf9..db82c5a7 100644 --- a/packages/core/android/src/main/java/io/embrace/embracewrapper/EmbraceManagerModule.java +++ b/packages/core/android/src/main/java/io/embrace/embracewrapper/EmbraceManagerModule.java @@ -165,7 +165,7 @@ public void clearAllUserPersonas(Promise promise) { @ReactMethod public void logMessageWithSeverityAndProperties(String message, String severity, ReadableMap properties, - Promise promise) { + String stacktrace, Promise promise) { try { final Map props = properties != null ? properties.toHashMap() : null; if (severity.equals("info")) { diff --git a/packages/core/ios/RNEmbrace/EmbraceManager.m b/packages/core/ios/RNEmbrace/EmbraceManager.m index 06b3d292..0e7e7c2a 100755 --- a/packages/core/ios/RNEmbrace/EmbraceManager.m +++ b/packages/core/ios/RNEmbrace/EmbraceManager.m @@ -84,6 +84,7 @@ @interface RCT_EXTERN_MODULE(EmbraceManager, NSObject) RCT_EXTERN_METHOD(logMessageWithSeverityAndProperties:(NSString *)message severity:(NSString *)severity properties:(NSDictionary)properties + stacktrace:(NSString *)stacktrace resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) diff --git a/packages/core/ios/RNEmbrace/EmbraceManager.swift b/packages/core/ios/RNEmbrace/EmbraceManager.swift index 2fde4c6e..a9e25f2d 100644 --- a/packages/core/ios/RNEmbrace/EmbraceManager.swift +++ b/packages/core/ios/RNEmbrace/EmbraceManager.swift @@ -301,20 +301,26 @@ class EmbraceManager: NSObject { } } - @objc(logMessageWithSeverityAndProperties:severity:properties:resolver:rejecter:) + @objc(logMessageWithSeverityAndProperties:severity:properties:stacktrace:resolver:rejecter:) func logMessageWithSeverityAndProperties( _ message: String, severity: String, properties: NSDictionary, + stacktrace: String, resolver resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock ) { let severityValue = self.severityFromString(from: severity) - guard let attributes = properties as? [String: String] else { + guard var attributes = properties as? [String: String] else { reject("LOG_MESSAGE_INVALID_PROPERTIES", "Properties should be [String: String]", nil) return } + + if (!stacktrace.isEmpty) { + attributes.updateValue(stacktrace, forKey: "exception.stacktrace") + } + Embrace.client?.log( message, severity: severityValue, diff --git a/packages/core/src/__tests__/embrace.test.ts b/packages/core/src/__tests__/embrace.test.ts index 4975c6de..28dda6c8 100644 --- a/packages/core/src/__tests__/embrace.test.ts +++ b/packages/core/src/__tests__/embrace.test.ts @@ -70,8 +70,14 @@ jest.mock("react-native", () => ({ message: string, severity: string, properties: Record, + stacktrace: string, ) => - mockLogMessageWithSeverityAndProperties(message, severity, properties), + mockLogMessageWithSeverityAndProperties( + message, + severity, + properties, + stacktrace, + ), logHandledError: (message: string, properties?: Record) => mockLogHandledError(message, properties), addUserPersona: (persona: string) => mockAddUserPersona(persona), @@ -133,8 +139,15 @@ jest.mock("react-native", () => ({ }, })); +const mockSt = "this is a fake stack trace"; const testView = "View"; +const mockGenerateStackTrace = jest.fn(); +jest.mock("../utils/ErrorUtil", () => ({ + ...jest.requireActual("../utils/ErrorUtil"), + generateStackTrace: () => mockGenerateStackTrace(), +})); + describe("User Identifier Tests", () => { const testUserId = "testUser"; beforeEach(() => { @@ -182,6 +195,10 @@ describe("Logs Test", () => { const INFO = "info"; const ERROR = "error"; + beforeEach(() => { + mockGenerateStackTrace.mockReturnValue(mockSt); + }); + test("addBreadcrumb", async () => { await addBreadcrumb(testView); expect(mockAddBreadcrumb).toHaveBeenCalledWith(testView); @@ -199,18 +216,25 @@ describe("Logs Test", () => { const testProps = {foo: "bar"}; test.each` - message | severity | properties - ${testMessage} | ${INFO} | ${testProps} - ${testMessage} | ${WARNING} | ${testProps} - ${testMessage} | ${ERROR} | ${testProps} - `("should run $severity log", async ({message, severity, properties}) => { - await logMessage(message, severity, properties); - expect(mockLogMessageWithSeverityAndProperties).toHaveBeenCalledWith( - message, - severity, - properties, - ); - }); + message | severity | properties | stackTrace + ${testMessage} | ${INFO} | ${testProps} | ${""} + ${testMessage} | ${INFO} | ${testProps} | ${""} + ${testMessage} | ${WARNING} | ${testProps} | ${mockSt} + ${testMessage} | ${WARNING} | ${testProps} | ${mockSt} + ${testMessage} | ${ERROR} | ${testProps} | ${mockSt} + ${testMessage} | ${ERROR} | ${testProps} | ${mockSt} + `( + "should run $severity log", + async ({message, severity, properties, stackTrace}) => { + await logMessage(message, severity, properties); + expect(mockLogMessageWithSeverityAndProperties).toHaveBeenCalledWith( + message, + severity, + properties, + stackTrace, + ); + }, + ); }); test("logInfo", async () => { @@ -218,7 +242,8 @@ describe("Logs Test", () => { expect(mockLogMessageWithSeverityAndProperties).toHaveBeenCalledWith( `test message`, INFO, - {}, + undefined, + "", ); }); @@ -227,7 +252,8 @@ describe("Logs Test", () => { expect(mockLogMessageWithSeverityAndProperties).toHaveBeenCalledWith( `test message`, WARNING, - {}, + undefined, + mockSt, ); }); @@ -236,7 +262,8 @@ describe("Logs Test", () => { expect(mockLogMessageWithSeverityAndProperties).toHaveBeenCalledWith( `test message`, ERROR, - {}, + undefined, + mockSt, ); }); }); @@ -289,7 +316,9 @@ describe("Custom Views Tests", () => { }); test("endView", async () => { - await endView(testView); + const promiseToResolve = endView(testView); + jest.runAllTimers(); + await promiseToResolve; expect(mockEndView).toHaveBeenCalledWith(testView); }); }); @@ -308,12 +337,19 @@ describe("Session Properties Tests", () => { describe("Payers Test", () => { test("setUserAsPayer", async () => { - const result = await setUserAsPayer(); + const promiseToResolve = setUserAsPayer(); + jest.runAllTimers(); + const result = await promiseToResolve; + expect(mockSetUserAsPayer).toHaveBeenCalled(); expect(result).toBe(false); }); test("clearUserAsPayer", async () => { - const result = await clearUserAsPayer(); + const promiseToResolve = clearUserAsPayer(); + + jest.runAllTimers(); + const result = await promiseToResolve; + expect(mockClearUserAsPayer).toHaveBeenCalled(); expect(result).toBe(false); }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4d0a4f8a..b256b638 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,7 +3,7 @@ import {NativeModules, Platform} from "react-native"; import * as embracePackage from "../package.json"; -import {handleGlobalError} from "./utils/ErrorUtil"; +import {generateStackTrace, handleGlobalError} from "./utils/ErrorUtil"; import {ApplyInterceptorStrategy} from "./networkInterceptors/ApplyInterceptor"; import {SessionStatus} from "./interfaces/Types"; import { @@ -121,15 +121,18 @@ export const initialize = async ({ allRejections: true, onUnhandled: (_: unknown, error: Error) => { let message = `${UNHANDLED_PROMISE_REJECTION_PREFIX}: ${error}`; + let stackTrace = ""; if (error instanceof Error) { message = `${UNHANDLED_PROMISE_REJECTION_PREFIX}: ${error.message}`; + stackTrace = error.stack || ""; } return NativeModules.EmbraceManager.logMessageWithSeverityAndProperties( message, ERROR, {}, + stackTrace, ); }, onHandled: noOp, @@ -205,10 +208,13 @@ export const logMessage = ( severity: "info" | "warning" | "error" = "error", properties?: Properties, ): Promise => { + const stacktrace = severity === INFO ? "" : generateStackTrace(); + return NativeModules.EmbraceManager.logMessageWithSeverityAndProperties( message, severity, - properties || {}, + properties, + stacktrace, ); }; diff --git a/packages/core/src/utils/ErrorUtil.ts b/packages/core/src/utils/ErrorUtil.ts index 68598d42..bbd71bb5 100644 --- a/packages/core/src/utils/ErrorUtil.ts +++ b/packages/core/src/utils/ErrorUtil.ts @@ -15,4 +15,9 @@ const handleGlobalError: GlobalErrorHandler = handleError(error, callback); }; -export {handleGlobalError}; +const generateStackTrace = (): string => { + const err = new Error(); + return err.stack || ""; +}; + +export {handleGlobalError, generateStackTrace}; diff --git a/packages/core/test-project/ios/Podfile.lock b/packages/core/test-project/ios/Podfile.lock index bc787802..59d4fa33 100644 --- a/packages/core/test-project/ios/Podfile.lock +++ b/packages/core/test-project/ios/Podfile.lock @@ -1452,7 +1452,7 @@ SPEC CHECKSUMS: React-utils: 4476b7fcbbd95cfd002f3e778616155241d86e31 ReactCommon: ecad995f26e0d1e24061f60f4e5d74782f003f12 SocketRocket: abac6f5de4d4d62d24e11868d7a2f427e0ef940d - Yoga: ae3c32c514802d30f687a04a6a35b348506d411f + Yoga: 2f71ecf38d934aecb366e686278102a51679c308 PODFILE CHECKSUM: 8a247e59201368a342e5530a540a10f61aa7e0f3 diff --git a/packages/core/test-project/ios/RNEmbraceTests/RNEmbraceTests.swift b/packages/core/test-project/ios/RNEmbraceTests/RNEmbraceTests.swift index f6877b19..2bda8746 100644 --- a/packages/core/test-project/ios/RNEmbraceTests/RNEmbraceTests.swift +++ b/packages/core/test-project/ios/RNEmbraceTests/RNEmbraceTests.swift @@ -191,6 +191,7 @@ class EmbraceLogsTests: XCTestCase { func testLogMessageWithSeverity() async throws { module.logMessageWithSeverityAndProperties("my log message", severity:"warning", properties: NSDictionary(), + stacktrace: "", resolver: promise.resolve, rejecter: promise.reject) let exportedLogs = try await getExportedLogs() @@ -200,14 +201,16 @@ class EmbraceLogsTests: XCTestCase { XCTAssertEqual(exportedLogs[0].severity?.description, "WARN") XCTAssertEqual(exportedLogs[0].body?.description, "my log message") XCTAssertEqual(exportedLogs[0].attributes["emb.type"]!.description, "sys.log") + XCTAssertNil(exportedLogs[0].attributes["exception.stacktrace"]) } - func testLogMessageWithSeverityAndProperties() async throws { module.logMessageWithSeverityAndProperties("my log message", severity:"error", properties: NSDictionary(dictionary: [ "prop1": "foo", "prop2": "bar" - ]), resolver: promise.resolve, rejecter: promise.reject) + ]), + stacktrace: "", + resolver: promise.resolve, rejecter: promise.reject) let exportedLogs = try await getExportedLogs() @@ -218,9 +221,25 @@ class EmbraceLogsTests: XCTestCase { XCTAssertEqual(exportedLogs[0].attributes["emb.type"]!.description, "sys.log") XCTAssertEqual(exportedLogs[0].attributes["prop1"]!.description, "foo") XCTAssertEqual(exportedLogs[0].attributes["prop2"]!.description, "bar") + XCTAssertNil(exportedLogs[0].attributes["exception.stacktrace"]) } + func testLogMessageWithStackTrace() async throws { + module.logMessageWithSeverityAndProperties("my log message", severity:"warning", properties: NSDictionary(), + stacktrace: "my stack trace", + resolver: promise.resolve, rejecter: promise.reject) + + let exportedLogs = try await getExportedLogs() + + XCTAssertEqual(promise.resolveCalls.count, 1) + XCTAssertEqual(exportedLogs.count, 1) + XCTAssertEqual(exportedLogs[0].severity?.description, "WARN") + XCTAssertEqual(exportedLogs[0].body?.description, "my log message") + XCTAssertEqual(exportedLogs[0].attributes["emb.type"]!.description, "sys.log") + XCTAssertEqual(exportedLogs[0].attributes["exception.stacktrace"]!.description, "my stack trace") + } + } class EmbraceSpansTests: XCTestCase {