unit Keyman.System.KeymanSentryClient; interface uses System.Classes, System.SysUtils, Sentry.Client; type TKeymanSentryClientProject = (kscpDesktop, kscpDeveloper); TKeymanSentryClientFlags = set of (kscfCaptureExceptions, kscfShowUI, kscfTerminate); TKeymanSentryClient = class private class var FInstance: TKeymanSentryClient; class var FClient: TSentryClient; class var FOnBeforeShutdown: TNotifyEvent; private FFlags: TKeymanSentryClientFlags; FProject: TKeymanSentryClientProject; procedure ClientAfterEvent(Sender: TObject; EventType: TSentryClientEventType; const EventID, EventClassName, Message: string; var EventAction: TSentryClientEventAction); constructor Create(SentryClientClass: TSentryClientClass; AProject: TKeymanSentryClientProject; const ALogger: string; AFlags: TKeymanSentryClientFlags); procedure ReportRemoteErrors(const childEventID: string); procedure NestedValidate(Force: Boolean); procedure NestedValidateAccessViolation; procedure NestedValidateDelphiException; procedure NestedValidateFloatingPointException; class function GetEnabled: Boolean; static; public destructor Destroy; override; class procedure Validate(Force: Boolean = False); class procedure ReportHandledException(E: Exception; const Message: string = ''; IncludeStack: Boolean = True); class procedure ReportMessage(const Message: string; IncludeStack: Boolean = True); class procedure Breadcrumb(const BreadcrumbType, Message: string; const Category: string = ''; const Level: string = 'info'); class procedure Start(SentryClientClass: TSentryClientClass; AProject: TKeymanSentryClientProject; const ALogger: string; AFlags: TKeymanSentryClientFlags = [kscfCaptureExceptions, kscfShowUI, kscfTerminate]); class procedure Stop; class property Client: TSentryClient read FClient; class property Enabled: Boolean read GetEnabled; class property Instance: TKeymanSentryClient read FInstance; class property OnBeforeShutdown: TNotifyEvent read FOnBeforeShutdown write FOnBeforeShutdown; public const LOGGER_DEVELOPER_IDE = 'KeymanDeveloper.IDE'; const LOGGER_DEVELOPER_TOOLS = 'KeymanDeveloper.Tools'; const LOGGER_DESKTOP = 'KeymanWindows'; const LOGGER_DESKTOP_ENGINE = 'KeymanWindows.Engine'; const S_Sentry_ViewEvent_URL = 'https://sentry.io/organizations/keyman/projects/%0:s/events/%1:s/'; // Do not localize end; implementation uses System.Generics.Collections, System.JSON, System.StrUtils, System.Win.ComObj, System.Win.Registry, {$IF NOT DEFINED(CONSOLE)} {$IF NOT DEFINED(SENTRY_NOVCL)} System.UITypes, Vcl.Dialogs, Vcl.Forms, {$ENDIF} {$ENDIF} sentry, KeymanPaths, KeymanVersion, RegistryKeys, utilexecute; const SENTRY_DSN_DESKTOP = 'https://92eb58e6005d47daa33c9c9e39458eb7@o1005580.ingest.sentry.io/5983518'; SENTRY_DSN_DEVELOPER = 'https://39b25a09410349a58fe12aaf721565af@o1005580.ingest.sentry.io/5983519'; SENTRY_PROJECT_NAME_DESKTOP = 'keyman-windows'; SENTRY_PROJECT_NAME_DEVELOPER = 'keyman-developer'; //SENTRY_DSN = 'https://7b1ff1dae2c8495b84f90dadcf512b84@sentry.io/4853461'; <-- this is a testing-only host // Settings valid on development machines only: KEYMAN_ROOT = 'KEYMAN_ROOT'; // environment variable {$IFDEF WIN64} DEV_SENTRY_PATH = 'common\windows\delphi\ext\sentry\sentry.x64.dll'; {$ELSE WIN64} DEV_SENTRY_PATH = 'common\windows\delphi\ext\sentry\sentry.dll'; {$ENDIF} { TKeymanSentryClient } function FindSentryDLL: string; var keyman_root: string; begin // If we are in a development situation, use the dev version // of the DLL. Beware mixing and matching which can happen // when testing DLLs that are registered and loaded from // Program Files if Keyman is installed. if TKeymanPaths.RunningFromSource(keyman_root) then begin Result := keyman_root + DEV_SENTRY_PATH; if FileExists(Result) then Exit; end; // When installed, it should be in the sentry-x.y.z folder for the executable Result := ExtractFilePath(ParamStr(0)) + '\sentry-'+SENTRY_SDK_VERSION+'\' + sentry_dll; if FileExists(Result) then Exit; Result := ''; end; class procedure TKeymanSentryClient.Breadcrumb(const BreadcrumbType, Message, Category, Level: string); begin if Enabled then Client.Breadcrumb(BreadcrumbType, Message, Category, Level); end; procedure TKeymanSentryClient.ClientAfterEvent(Sender: TObject; EventType: TSentryClientEventType; const EventID, EventClassName, Message: string; var EventAction: TSentryClientEventAction); var AppID, ProjectName: string; {$IF NOT DEFINED(CONSOLE)} ApplicationTitle, CommandLine: string; tsysinfopath, enginepath: string; {$ENDIF} begin if EventType = scetException then begin // We need to look for a kmcomapi errlog and report that if FClient.ReportExceptions then ReportRemoteErrors(EventID); if Assigned(FOnBeforeShutdown) then FOnBeforeShutdown(Self); if kscfShowUI in FFlags then begin AppID := LowerCase(ChangeFileExt(ExtractFileName(ParamStr(0)), ''))+'-'+CKeymanVersionInfo.VersionWithTag; if FProject = kscpDesktop then ProjectName := SENTRY_PROJECT_NAME_DESKTOP else ProjectName := SENTRY_PROJECT_NAME_DEVELOPER; {$IF DEFINED(CONSOLE)} // Write to console writeln(ErrOutput, 'Fatal error '+EventClassName+': '+Message); if FClient.ReportExceptions then begin writeln(ErrOutput, 'This error has been automatically reported to the Keyman team.'); writeln(ErrOutput, ' Identifier: '+EventID); writeln(ErrOutput, ' Application: '+AppID); writeln(ErrOutput, ' Reported at: '+Format(S_Sentry_ViewEvent_URL, [ProjectName, EventID])); end; writeln(ErrOutput); {$ELSE} // Launch external gui exception dialog app. // Usage: tsysinfo -c [sentryprojectname [classname [message]]] {$IF NOT DEFINED(SENTRY_NOVCL)} if Assigned(Application) then ApplicationTitle := Application.Title else ApplicationTitle := AppID; {$ELSE} ApplicationTitle := AppID; {$ENDIF} CommandLine := Format('-c "%s" "%s" "%s" "%s" "%s" "%s"', [ IfThen(EventID = '', '_', EventID), IfThen(ApplicationTitle = '', ChangeFileExt(ExtractFileName(ParamStr(0)),''), ApplicationTitle), AppID, ProjectName, EventClassName, StringReplace(Message, '"', '""', [rfReplaceAll]) ]); try tsysinfopath := TKeymanPaths.KeymanEngineInstallPath('tsysinfo.exe'); enginepath := TKeymanPaths.KeymanEngineInstallPath(''); except on E:EKeymanPath do begin tsysinfopath := ''; enginepath := ''; end; end; if (tsysinfopath = '') or not TUtilExecute.Shell(0, tsysinfopath, enginepath, CommandLine) then begin {$IF NOT DEFINED(SENTRY_NOVCL)} MessageDlg(Application.Title+' has had a fatal error '+EventID+'. '+ 'This error has been automatically reported to the Keyman team.', mtError, [mbOK], 0); {$ENDIF} end; {$ENDIF} end; // Abort process: we'll get TSentryClient to clean up and crash if kscfTerminate in FFlags then EventAction := sceaTerminate; end; end; class procedure TKeymanSentryClient.ReportHandledException(E: Exception; const Message: string = ''; IncludeStack: Boolean = True); var text: string; begin if not Enabled then Exit; if Message <> '' then text := Message + ': ' else text := ''; if E is EOleException then text := Format('%s(%x): %s%s', [E.ClassName, (E as EOleException).ErrorCode, text, E.Message]) else if E is EOSError then text := Format('%s(%d): %s%s', [E.ClassName, (E as EOSError).ErrorCode, text, E.Message]) else text := Format('%s: %s%s', [E.ClassName, text, E.Message]); Client.MessageEvent(TSentryLevel.SENTRY_LEVEL_WARNING, text, IncludeStack); end; class procedure TKeymanSentryClient.ReportMessage(const Message: string; IncludeStack: Boolean); begin if not Enabled then Exit; Client.MessageEvent(TSentryLevel.SENTRY_LEVEL_WARNING, Message, IncludeStack); end; procedure TKeymanSentryClient.ReportRemoteErrors(const childEventID: string); var errlogfile: string; o: TJSONObject; event: sentry_value_t; stack: TJSONArray; i: Integer; frames: sentry_value_t; stacktrace: sentry_value_t; threads: sentry_value_t; thread: sentry_value_t; frame: sentry_value_t; begin errlogfile := TKeymanPaths.ErrorLogPath('kmcomapi'); // I2824 if FileExists(errlogfile) then begin // We'll use the Sentry API directly here to construct a crash report from // kmcomapi. with TStringStream.Create('', TEncoding.UTF8) do try LoadFromFile(errlogfile); o := TJSONObject.ParseJSONValue(DataString) as TJSONObject; finally Free; end; DeleteFile(errlogfile); (* When we set exception information, the report is corrupted. Not sure why. So for now we won't create as an exception event. We still get all the information we want from this. Investigating this further at https://forum.sentry.io/t/corrupted-display-when-exception-data-is-set-using-native-sdk/9167/2 exc := sentry_value_new_object; sentry_value_set_by_key(exc, 'type', sentry_value_new_string(PAnsiChar(UTF8Encode(ExceptionClassName)))); sentry_value_set_by_key(exc, 'value', sentry_value_new_string(PAnsiChar(UTF8Encode(Message)))); sentry_value_set_by_key(event, 'exception', exc); *) event := sentry_value_new_event; sentry_value_set_by_key(event, 'message', sentry_value_new_string(PAnsiChar(UTF8Encode(o.Values['message'].Value)))); // Construct the stack trace stack := o.Values['stack'] as TJSONArray; frames := sentry_value_new_list; for i := 0 to stack.Count - 1 do begin frame := sentry_value_new_object; sentry_value_set_by_key(frame, 'instruction_addr', sentry_value_new_string(PAnsiChar(AnsiString(stack.Items[i].Value)))); sentry_value_append(frames, frame); end; stacktrace := sentry_value_new_object; sentry_value_set_by_key(stacktrace, 'frames', frames); threads := sentry_value_new_list; thread := sentry_value_new_object; sentry_value_set_by_key(thread, 'stacktrace', stacktrace); sentry_value_append(threads, thread); sentry_value_set_by_key(event, 'threads', threads); sentry_set_extra('child_event', sentry_value_new_string(PAnsiChar(AnsiString(childEventID)))); sentry_capture_event(event); end; end; constructor TKeymanSentryClient.Create(SentryClientClass: TSentryClientClass; AProject: TKeymanSentryClientProject; const ALogger: string; AFlags: TKeymanSentryClientFlags); var reg: TRegistry; o: TSentryClientOptions; f: TSentryClientFlags; path, RegKey: string; begin Assert(not Assigned(FInstance)); FInstance := Self; FProject := AProject; FFlags := AFlags; if not Enabled then Exit; path := FindSentryDLL; if path = '' then // Cannot find sentry.dll Exit; sentry_set_library_path(path); o.Debug := False; if AProject = kscpDesktop then o.DSN := SENTRY_DSN_DESKTOP else o.DSN := SENTRY_DSN_DEVELOPER; // Note: system proxy is used automatically if no proxy is defined o.Release := CKeymanVersionInfo.VersionGitTag; // matches git tag o.Environment := CKeymanVersionInfo.Environment; // stable, beta, alpha, test, local if kscfCaptureExceptions in FFlags then f := [scfCaptureExceptions] else f := []; // Load the registry settings for privacy settings if AProject = kscpDesktop then RegKey := SRegKey_KeymanEngine_CU else RegKey := SRegKey_IDEOptions_CU; reg := TRegistry.Create; try if reg.OpenKeyReadOnly(RegKey) then begin if not reg.ValueExists(SRegValue_AutomaticallyReportErrors) or reg.ReadBool(SRegValue_AutomaticallyReportErrors) then Include(f, scfReportExceptions); if not reg.ValueExists(SRegValue_AutomaticallyReportUsage) or reg.ReadBool(SRegValue_AutomaticallyReportUsage) then Include(f, scfReportMessages); end else begin Include(f, scfReportExceptions); Include(f, scfReportMessages); end; finally reg.Free; end; o.HandlerPath := ExtractFilePath(path) + 'crashpad_handler.exe'; o.DatabasePath := TKeymanPaths.ErrorLogPath + 'sentry-0.6.0-db'; FClient := SentryClientClass.Create(o, ALogger, f); FClient.OnAfterEvent := ClientAfterEvent; // We used the 'Started' event when testing Sentry integration in 14.0 alpha // but we don't want or need it for stable. // FClient.MessageEvent(Sentry.Client.SENTRY_LEVEL_INFO, 'Started '+ALogger); end; destructor TKeymanSentryClient.Destroy; begin if FInstance = Self then FInstance := nil; FreeAndNil(FClient); inherited Destroy; end; class function TKeymanSentryClient.GetEnabled: Boolean; begin Result := TSentryClient.Enabled; end; class procedure TKeymanSentryClient.Start(SentryClientClass: TSentryClientClass; AProject: TKeymanSentryClientProject; const ALogger: string; AFlags: TKeymanSentryClientFlags); begin TKeymanSentryClient.Create(SentryClientClass, AProject, ALogger, AFlags); end; class procedure TKeymanSentryClient.Stop; begin FreeAndNil(FInstance); end; // // With this function, we throw a test crash event to make sure that: // a) exception hooking is in place // b) events are correctly sent through // c) symbolication is working // d) privacy options are correctly checked // // We nest some function calls for a more expressive stack // class procedure TKeymanSentryClient.Validate(Force: Boolean); begin if not Enabled then Exit; TKeymanSentryClient.Instance.NestedValidate(Force); end; procedure TKeymanSentryClient.NestedValidate(Force: Boolean); begin if Force then NestedValidateDelphiException else if ParamStr(1) = '-sentry-client-test-exception' then begin // Undocumented test parameter if ParamStr(2) = '' then NestedValidateDelphiException else if ParamStr(2) = 'av' then NestedValidateAccessViolation else if ParamStr(2) = 'fp' then NestedValidateFloatingPointException; end; end; procedure TKeymanSentryClient.NestedValidateDelphiException; begin raise ESentryTest.Create('Just testing Sentry'); end; procedure TKeymanSentryClient.NestedValidateAccessViolation; var p: PByte; begin // Force an Access Violation p := nil; p^ := 0; end; procedure TKeymanSentryClient.NestedValidateFloatingPointException; var n: Double; begin // Force a floating point exception {$IF DEFINED(CONSOLE)} writeln('Attempting FP Exception'); {$ENDIF} n := 0; n := 1 / n; writeln(n); // We'll never get here, but this stops a compiler warning end; end.