From 6f6bec8b226b37145cd6e5910c83f6201917681e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Thu, 24 Oct 2019 16:55:42 +0200 Subject: [PATCH] Add optional sentry handling of frontend errors --- app/views/layouts/base.html.erb | 5 + config/initializers/secure_headers.rb | 4 + frontend/npm-shrinkwrap.json | 414 +++--------------- frontend/package.json | 2 +- .../components/user/current-user.service.ts | 16 +- .../common/openproject-common.module.ts | 21 +- .../app/modules/hal/openproject-hal.module.ts | 3 +- .../hal/services/hal-aware-error-handler.ts | 7 +- frontend/src/app/sentry/sentry-reporter.ts | 156 +++++++ frontend/src/main.ts | 7 +- frontend/src/typings/shims.d.ts | 3 + lib/open_project/configuration.rb | 6 +- 12 files changed, 288 insertions(+), 356 deletions(-) create mode 100644 frontend/src/app/sentry/sentry-reporter.ts diff --git a/app/views/layouts/base.html.erb b/app/views/layouts/base.html.erb index 6c89255689..b37b69a95d 100644 --- a/app/views/layouts/base.html.erb +++ b/app/views/layouts/base.html.erb @@ -49,9 +49,14 @@ See docs/COPYRIGHT.rdoc for more details. <% unless User.current.anonymous? %> <% end %> + <% if OpenProject::Configuration.sentry_dsn.present? %> + <%= tag :meta, name: 'openproject_sentry', data: { dsn: OpenProject::Configuration.sentry_dsn } %> + <% end %> + =0.6.0", - "xmlbuilder": "~9.0.1" - }, - "dependencies": { - "sax": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", - "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==", - "dev": true - } - } - }, - "xmlbuilder": { - "version": "9.0.7", - "resolved": "http://registry.npmjs.org/xmlbuilder/-/xmlbuilder-9.0.7.tgz", - "integrity": "sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0=", - "dev": true - }, "xmlhttprequest-ssl": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index 622cf532c2..d8129feb93 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -25,7 +25,6 @@ "karma-jasmine-html-reporter": "^1.4.2", "karma-ng-html2js-preprocessor": "^1.0.0", "postcss-loader": "^3.0.0", - "protractor": "^5.4.2", "sorted-object": "^2.0.1", "ts-node": "~8.3.0", "tslint": "5.18.0" @@ -45,6 +44,7 @@ "@angular/router": "8.2.9", "@ng-select/ng-option-highlight": "0.0.2", "@ng-select/ng-select": "^3.0.6", + "@sentry/browser": "^5.7.1", "@types/assertion-error": "^1.0.30", "@types/chart.js": "^2.8.1", "@types/codemirror": "0.0.76", diff --git a/frontend/src/app/components/user/current-user.service.ts b/frontend/src/app/components/user/current-user.service.ts index e464a5427b..2ac79e97a0 100644 --- a/frontend/src/app/components/user/current-user.service.ts +++ b/frontend/src/app/components/user/current-user.service.ts @@ -31,10 +31,22 @@ import {Injectable} from "@angular/core"; @Injectable() export class CurrentUserService { public get isLoggedIn() { - return jQuery('meta[name=current_user]').length > 0; + return this.userMeta.length > 0; } public get userId() { - return jQuery('meta[name=current_user]').data('id'); + return this.userMeta.data('id'); + } + + public get name() { + return this.userMeta.data('name'); + } + + public get mail() { + return this.userMeta.data('mail'); + } + + private get userMeta():JQuery { + return jQuery('meta[name=current_user]'); } } diff --git a/frontend/src/app/modules/common/openproject-common.module.ts b/frontend/src/app/modules/common/openproject-common.module.ts index 5704621486..e62e686fac 100644 --- a/frontend/src/app/modules/common/openproject-common.module.ts +++ b/frontend/src/app/modules/common/openproject-common.module.ts @@ -63,7 +63,7 @@ import {SortHeaderDirective} from 'core-components/wp-table/sort-header/sort-hea import {ZenModeButtonComponent} from 'core-components/wp-buttons/zen-mode-toggle-button/zen-mode-toggle-button.component'; import {OPContextMenuComponent} from 'core-components/op-context-menu/op-context-menu.component'; import {TimezoneService} from 'core-components/datetime/timezone.service'; -import {UIRouterModule} from "@uirouter/angular"; +import {StateService, UIRouterModule} from "@uirouter/angular"; import {PortalModule} from "@angular/cdk/portal"; import {CommonModule} from "@angular/common"; import {CollapsibleSectionComponent} from "core-app/modules/common/collapsible-section/collapsible-section.component"; @@ -94,9 +94,28 @@ import {ShowSectionDropdownComponent} from "core-app/modules/common/hide-section import {IconTriggeredContextMenuComponent} from "core-components/op-context-menu/icon-triggered-context-menu/icon-triggered-context-menu.component"; import {NgSelectModule} from "@ng-select/ng-select"; import {NgOptionHighlightModule} from "@ng-select/ng-option-highlight"; +import {CurrentProjectService} from "core-components/projects/current-project.service"; +import {CurrentUserService} from "core-components/user/current-user.service"; export function bootstrapModule(injector:Injector) { return () => { + // Ensure error reporter is run + const currentProject = injector.get(CurrentProjectService); + const currentUser = injector.get(CurrentUserService); + const routerState = injector.get(StateService); + + window.ErrorReporter.addContext((scope) => { + if (currentUser.isLoggedIn) { + scope.setUser({ name: currentUser.name, id: currentUser.userId, email: currentUser.mail }); + } + + if (currentProject.inProjectContext) { + scope.setTag('project', currentProject.identifier!); + } + + scope.setExtra('router state', routerState.current.name); + }); + const hookService = injector.get(HookService); hookService.register('openProjectAngularBootstrap', () => { return [ diff --git a/frontend/src/app/modules/hal/openproject-hal.module.ts b/frontend/src/app/modules/hal/openproject-hal.module.ts index 084e7f7075..7a4ffaf541 100644 --- a/frontend/src/app/modules/hal/openproject-hal.module.ts +++ b/frontend/src/app/modules/hal/openproject-hal.module.ts @@ -44,7 +44,6 @@ import {OpenProjectHeaderInterceptor} from 'core-app/modules/hal/http/openprojec import {UserDmService} from 'core-app/modules/hal/dm-services/user-dm.service'; import {ProjectDmService} from 'core-app/modules/hal/dm-services/project-dm.service'; import {HalResourceSortingService} from "core-app/modules/hal/services/hal-resource-sorting.service"; -import {HalAwareErrorHandler} from "core-app/modules/hal/services/hal-aware-error-handler"; import {GridDmService} from "core-app/modules/hal/dm-services/grid-dm.service"; import {TimeEntryDmService} from './dm-services/time-entry-dm.service'; import {CommonModule} from "@angular/common"; @@ -55,7 +54,7 @@ import {QueryOrderDmService} from "core-app/modules/hal/dm-services/query-order- import {MembershipDmService} from "core-app/modules/hal/dm-services/membership-dm.service"; import {HalEventsService} from "core-app/modules/hal/services/hal-events.service"; import {HalResourceNotificationService} from "core-app/modules/hal/services/hal-resource-notification.service"; -import {HalResourceEditingService} from "core-app/modules/fields/edit/services/hal-resource-editing.service"; +import {HalAwareErrorHandler} from "core-app/modules/hal/services/hal-aware-error-handler"; @NgModule({ imports: [ diff --git a/frontend/src/app/modules/hal/services/hal-aware-error-handler.ts b/frontend/src/app/modules/hal/services/hal-aware-error-handler.ts index c5601876a9..764e77384c 100644 --- a/frontend/src/app/modules/hal/services/hal-aware-error-handler.ts +++ b/frontend/src/app/modules/hal/services/hal-aware-error-handler.ts @@ -13,7 +13,7 @@ export class HalAwareErrorHandler extends ErrorHandler { super(); } - public handleError(error:any) { + public handleError(error:unknown) { let message:string = this.text.internal_error; if (error instanceof ErrorResource) { @@ -22,7 +22,10 @@ export class HalAwareErrorHandler extends ErrorHandler { } else if (error instanceof HalResource) { console.error("Returned hal resource %O", error); message += `Resource returned ${error.name}`; - } else { + } else if (error instanceof Error) { + window.ErrorReporter.captureException(error); + } else if (typeof error === 'string') { + window.ErrorReporter.captureMessage(error); message = error; } diff --git a/frontend/src/app/sentry/sentry-reporter.ts b/frontend/src/app/sentry/sentry-reporter.ts new file mode 100644 index 0000000000..ea2847b6b3 --- /dev/null +++ b/frontend/src/app/sentry/sentry-reporter.ts @@ -0,0 +1,156 @@ +// -- copyright +// OpenProject is a project management system. +// Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See doc/COPYRIGHT.rdoc for more details. +// ++ + +import {Scope} from "@sentry/hub"; +import {Severity} from "@sentry/types"; +import {environment} from "../../environments/environment"; +import {debugLog} from "core-app/helpers/debug_output"; + +export type ScopeCallback = (scope:Scope) => void; + + +export interface CaptureInterface { + /** Capture a message */ + captureMessage(msg:string, level?:MessageSeverity):void; + + /** Capture an exception(!) only */ + captureException(err:Error):void; +} + +export interface SentryClient extends CaptureInterface { + configureScope(scope:ScopeCallback):void; + withScope(scope:ScopeCallback):void; +} + +export interface ErrorReporter extends CaptureInterface { + /** Register a context callback handler */ + addContext(...callbacks:ScopeCallback[]):void; +} + +export type MessageSeverity = 'fatal'|'error'|'warning'|'log'|'info'|'debug'; + +interface QueuedMessage { + type:'captureMessage'|'captureException'; + args:any[]; +} + +export class SentryReporter implements ErrorReporter { + + private contextCallbacks:ScopeCallback[] = []; + + private messageStack:QueuedMessage[] = []; + + private readonly sentryConfigured:boolean = true; + + private client:any; + + constructor() { + const sentryElement = document.querySelector('meta[name=openproject_sentry]') as HTMLElement|null; + if (sentryElement) { + import('@sentry/browser').then((Sentry) => { + Sentry.init({ + dsn: sentryElement.dataset.dsn!, + debug: !environment.production, + }); + + this.sentryLoaded(Sentry); + }); + } else { + this.sentryConfigured = false; + this.messageStack = []; + } + } + + public sentryLoaded(client:any) { + this.client = client; + client.configureScope(this.setupContext.bind(this)); + + // Send all messages from before sentry got loaded + this.messageStack.forEach((item) => { + this[item.type].bind(this).apply(item.args); + }); + } + + public captureMessage(msg:string, severity:MessageSeverity = 'info') { + if (!this.client) { + return this.handleOfflineMessage('captureMessage', Array.from(arguments)); + } + + this.client.withScope((scope:Scope) => { + this.setupContext(scope); + this.client.captureMessage(msg, Severity.fromString(severity)); + }); + } + + public captureException(err:Error) { + if (!this.client) { + return this.handleOfflineMessage('captureException', Array.from(arguments)); + } + + this.client.withScope((scope:Scope) => { + this.setupContext(scope); + this.client.captureException(err); + }); + } + + public addContext(...callbacks:ScopeCallback[]):void { + this.contextCallbacks.push(...callbacks); + + if (this.client) { + /** Add to global context as well */ + callbacks.forEach(cb => this.client.configureScope(cb)); + } + } + + /** + * Remember a message or error for later handling + * @param type + * @param args + */ + private handleOfflineMessage(type:'captureMessage'|'captureException', args:any[]) { + if (this.sentryConfigured) { + this.messageStack.push({ type, args }); + } else { + console.log("[ErrorReporter] Would queue sentry message %O %O, but is not configured.", type, args); + } + } + + /** + * Set up the current scope for the event to be sent. + * @param scope + */ + private setupContext(scope:Scope) { + scope.setTag('locale', I18n.locale); + scope.setTag('domain', window.location.hostname); + scope.setTag('url_path', window.location.pathname); + scope.setTag('url_query', window.location.search); + + /** Execute callbacks */ + this.contextCallbacks.forEach(cb => cb(scope)); + } +} \ No newline at end of file diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 199047fb62..c25b75e1fb 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -7,6 +7,10 @@ import {platformBrowserDynamic} from '@angular/platform-browser-dynamic'; (window as any).global = window; +/** Load sentry integration as soon as possible */ +import {SentryReporter} from "core-app/sentry/sentry-reporter"; +window.ErrorReporter = new SentryReporter(); + require('./app/init-vendors'); require('./app/init-globals'); @@ -15,8 +19,9 @@ if (environment.production) { enableProdMode(); } + jQuery(function () { -// Due to the behaviour of the Edge browser we need to wait for 'DOM ready' + // Due to the behaviour of the Edge browser we need to wait for 'DOM ready' platformBrowserDynamic() .bootstrapModule(OpenProjectModule) .then(platformRef => { diff --git a/frontend/src/typings/shims.d.ts b/frontend/src/typings/shims.d.ts index c27a9914f5..2a2c7062fc 100644 --- a/frontend/src/typings/shims.d.ts +++ b/frontend/src/typings/shims.d.ts @@ -16,6 +16,8 @@ /// /// +import {ErrorReporter} from "core-app/sentry/sentry-reporter"; + declare module 'dom-autoscroller'; import {Injector} from '@angular/core'; @@ -50,6 +52,7 @@ declare global { appBasePath:string; ng2Injector:Injector; OpenProject:OpenProject; + ErrorReporter:ErrorReporter; } interface JQuery { diff --git a/lib/open_project/configuration.rb b/lib/open_project/configuration.rb index cf97a7e0f3..6d189837ba 100644 --- a/lib/open_project/configuration.rb +++ b/lib/open_project/configuration.rb @@ -157,7 +157,11 @@ module OpenProject 'show_warning_bars' => true, # Render storage information - 'show_storage_information' => true + 'show_storage_information' => true, + + # Log errors to sentry instance + 'sentry_dsn' => nil, + 'sentry_host' => 'https://sentry.openproject.com' } @config = nil