Skip to content

Commit 5e5848b

Browse files
authored
Log JS errors to dotnet (#2003)
1 parent 40c2ea9 commit 5e5848b

File tree

11 files changed

+139
-26
lines changed

11 files changed

+139
-26
lines changed

backend/FwLite/FwLiteShared/FwLiteSharedKernel.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ public static IServiceCollection AddFwLiteShared(this IServiceCollection service
3636
services.AddSingleton<ProjectEventBus>();
3737
services.AddSingleton<MiniLcmApiNotifyWrapperFactory>();
3838
services.AddScoped<JsEventListener>();
39+
services.AddScoped<JsInvokableLogger>();
3940
//this is scoped so that there will be once instance per blazor circuit, this prevents issues where the same instance is used when reloading the page.
4041
//it also avoids issues if there's multiple blazor circuits running at the same time
4142
services.AddScoped<FwLiteProvider>();

backend/FwLite/FwLiteShared/Services/FwLiteProvider.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ IServiceProvider services
3434
DotnetService.TroubleshootingService,
3535
DotnetService.MultiWindowService,
3636
DotnetService.JsEventListener,
37+
DotnetService.JsInvokableLogger,
3738
];
3839

3940
public static Type GetServiceType(DotnetService service) => service switch
@@ -51,6 +52,7 @@ IServiceProvider services
5152
DotnetService.TestingService => typeof(TestingService),
5253
DotnetService.MultiWindowService => typeof(IMultiWindowService),
5354
DotnetService.JsEventListener => typeof(JsEventListener),
55+
DotnetService.JsInvokableLogger => typeof(JsInvokableLogger),
5456
_ => throw new ArgumentOutOfRangeException(nameof(service), service, null)
5557
};
5658

@@ -107,5 +109,6 @@ public enum DotnetService
107109
TroubleshootingService,
108110
TestingService,
109111
MultiWindowService,
110-
JsEventListener
112+
JsEventListener,
113+
JsInvokableLogger,
111114
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
using Microsoft.JSInterop;
2+
using Microsoft.Extensions.Logging;
3+
4+
namespace FwLiteShared.Services;
5+
6+
public class JsInvokableLogger(ILogger<JsInvokableLogger> logger)
7+
{
8+
[JSInvokable]
9+
public Task Log(LogLevel level, string message)
10+
{
11+
logger.Log(level, message);
12+
return Task.CompletedTask;
13+
}
14+
}

backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
using FwLiteShared.Sync;
2727
using MiniLcm.Media;
2828
using MediaFile = MiniLcm.Media.MediaFile;
29+
using Microsoft.Extensions.Logging;
2930

3031
namespace FwLiteShared.TypeGen;
3132

@@ -172,6 +173,7 @@ private static void ConfigureFwLiteSharedTypes(ConfigurationBuilder builder)
172173
], exportBuilder => exportBuilder.WithPublicProperties());
173174

174175
builder.ExportAsEnum<FwEventType>().UseString();
176+
builder.ExportAsEnum<LogLevel>().UseString(false);
175177
var eventJsAttrs = typeof(IFwEvent).GetCustomAttributes<JsonDerivedTypeAttribute>();
176178
builder.ExportAsInterfaces(
177179
typeof(IFwEvent).Assembly.GetTypes()

frontend/viewer/src/lib/dotnet-types/generated-types/FwLiteShared/Services/DotnetService.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export enum DotnetService {
1616
TroubleshootingService = "TroubleshootingService",
1717
TestingService = "TestingService",
1818
MultiWindowService = "MultiWindowService",
19-
JsEventListener = "JsEventListener"
19+
JsEventListener = "JsEventListener",
20+
JsInvokableLogger = "JsInvokableLogger"
2021
}
2122
/* eslint-enable */
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/* eslint-disable */
2+
// This code was generated by a Reinforced.Typings tool.
3+
// Changes to this file may cause incorrect behavior and will be lost if
4+
// the code is regenerated.
5+
6+
import type {LogLevel} from '../../Microsoft/Extensions/Logging/LogLevel';
7+
8+
export interface IJsInvokableLogger
9+
{
10+
log(level: LogLevel, message: string) : Promise<void>;
11+
}
12+
/* eslint-enable */
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/* eslint-disable */
2+
// This code was generated by a Reinforced.Typings tool.
3+
// Changes to this file may cause incorrect behavior and will be lost if
4+
// the code is regenerated.
5+
6+
export enum LogLevel {
7+
Trace = 0,
8+
Debug = 1,
9+
Information = 2,
10+
Warning = 3,
11+
Error = 4,
12+
Critical = 5,
13+
None = 6
14+
}
15+
/* eslint-enable */
Lines changed: 79 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,24 @@
11
import {AppNotification} from '$lib/notifications/notifications';
2+
import type {IJsInvokableLogger} from '$lib/dotnet-types/generated-types/FwLiteShared/Services/IJsInvokableLogger';
3+
import {LogLevel} from '$lib/dotnet-types/generated-types/Microsoft/Extensions/Logging/LogLevel';
4+
import {delay} from '$lib/utils/time';
5+
import {useJsInvokableLogger} from '$lib/services/js-invokable-logger';
26

3-
function getPromiseRejectionMessage(event: PromiseRejectionEvent): string {
4-
if (event.reason instanceof Error) {
5-
return event.reason.message;
7+
type UnifiedErrorEvent = {
8+
message: string;
9+
error: unknown;
10+
at?: string;
11+
}
12+
13+
function unifyErrorEvent(event: ErrorEvent | PromiseRejectionEvent): UnifiedErrorEvent {
14+
if ('message' in event) {
15+
return { message: event.message, error: event.error, at: `${event.filename}:${event.lineno}:${event.colno}` };
616
} else if (typeof event.reason === 'string') {
7-
return event.reason;
17+
return { message: event.reason, error: null };
18+
} else if (event.reason instanceof Error) {
19+
return { message: event.reason.message, error: event.reason };
820
} else {
9-
return 'Unknown error';
21+
return { message: 'Unknown error', error: event.reason };
1022
}
1123
}
1224

@@ -24,30 +36,73 @@ System.InvalidOperationException: Everything is broken. Here's some ice cream.
2436
*/
2537
const dotnetErrorRegex = /^([\s\S]+?) {3}at /m;
2638

27-
function processErrorIntoDetails(message: string): {message: string, detail?: string} {
39+
function processErrorIntoDetails(event: UnifiedErrorEvent): {message: string, detail?: string} {
40+
const message = event.message;
2841
const match = dotnetErrorRegex.exec(message);
29-
if (!match) return {message};
30-
return {message: match[1].trim(), detail: message.substring(match[1].length).trim()};
42+
if (match) return {message: match[1].trim(), detail: message.substring(match[1].length).trim()};
43+
else if (event.error instanceof Error) return {message: message, detail: event.error.stack};
44+
else return {message};
3145
}
3246

3347
let setup = false;
3448
export function setupGlobalErrorHandlers() {
3549
if (setup) return;
3650
setup = true;
37-
window.addEventListener('error', (event: ErrorEvent) => {
38-
console.error('Global error', event);
39-
40-
if (suppressErrorNotification(event.message)) return;
41-
const {message: simpleMessage, detail} = processErrorIntoDetails(event.message);
42-
AppNotification.error(simpleMessage, detail, event.message);
43-
});
44-
45-
window.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => {
46-
const message = getPromiseRejectionMessage(event);
47-
//no need to log these because they already get logged by blazor.web.js
48-
49-
if (suppressErrorNotification(message)) return;
50-
const {message: simpleMessage, detail} = processErrorIntoDetails(message);
51-
AppNotification.error(simpleMessage, detail, message);
52-
});
51+
window.addEventListener('error', onErrorEvent);
52+
window.addEventListener('unhandledrejection', onErrorEvent);
53+
}
54+
55+
function onErrorEvent(event: ErrorEvent | PromiseRejectionEvent) {
56+
const errorEvent = unifyErrorEvent(event);
57+
void tryLogErrorToDotNet(errorEvent);
58+
if (suppressErrorNotification(errorEvent.message)) return;
59+
const {message: simpleMessage, detail} = processErrorIntoDetails(errorEvent);
60+
AppNotification.error(simpleMessage, detail);
61+
}
62+
63+
async function tryLogErrorToDotNet(error: UnifiedErrorEvent) {
64+
try {
65+
const details = getErrorString(error);
66+
if (details.includes('JsInvokableLogger')) return; // avoid potential infinite loop
67+
const logger = await tryGetLogger();
68+
if (logger) await logger.log(LogLevel.Error, details);
69+
else console.warn('No DotNet logger available to log error', error);
70+
} catch (err) {
71+
console.error('Failed to log error to DotNet', err);
72+
}
73+
}
74+
75+
// some very cheap durability.
76+
// As it is today, the logger service is available before our error handlers are registered
77+
async function tryGetLogger(): Promise<IJsInvokableLogger | undefined> {
78+
let logger = useJsInvokableLogger();
79+
if (logger) return logger;
80+
await delay(1);
81+
logger = useJsInvokableLogger();
82+
if (logger) return logger;
83+
await delay(1000);
84+
logger = useJsInvokableLogger();
85+
return logger;
86+
}
87+
88+
function getErrorString(event: UnifiedErrorEvent) {
89+
const details = [`Message: ${event.message}`];
90+
if (event.at) details.push(`at ${event.at}`);
91+
if (event.error instanceof Error) {
92+
const error: Error = event.error;
93+
if (error.stack) details.push(`Stack: ${error.stack}`);
94+
if (error.cause) details.push(`Cause: ${tryStringify(error.cause)}`);
95+
} else if (event.error) {
96+
details.push(`Error: ${tryStringify(event.error)}`);
97+
}
98+
99+
return details.join('\n');
100+
}
101+
102+
function tryStringify(value: unknown): string | undefined {
103+
try {
104+
return JSON.stringify(value);
105+
} catch {
106+
return '(failed-to-stringify)';
107+
}
53108
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import {DotnetService} from '$lib/dotnet-types';
2+
import type {IJsInvokableLogger} from '$lib/dotnet-types/generated-types/FwLiteShared/Services/IJsInvokableLogger';
3+
4+
export function useJsInvokableLogger(): IJsInvokableLogger | undefined {
5+
return window.lexbox.ServiceProvider.tryGetService(DotnetService.JsInvokableLogger);
6+
}

frontend/viewer/src/lib/services/service-provider-dotnet.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ export function wrapInProxy<K extends ServiceKey>(dotnetObject: DotNet.DotNetObj
5959
if (typeof prop !== 'string') return undefined;
6060
//runed resource calls stringify on values to check equality, so we don't want to pass the toJSON call through to the backend
6161
if (prop === 'toJSON') return undefined;
62+
//called to check if it's a promise or not
63+
if (prop === 'then') return undefined;
6264
const dotnetMethodName = uppercaseFirstLetter(prop);
6365
return async function proxyHandler(...args: unknown[]) {
6466
console.debug(`[Dotnet Proxy] Calling ${serviceName} method ${dotnetMethodName}`, args);

0 commit comments

Comments
 (0)