feat: move sentry configuration from frontend to api

This commit is contained in:
kolaente 2024-02-09 14:24:29 +01:00
parent 1899f16207
commit a0e770438d
Signed by: konrad
GPG Key ID: F40E70337AB24C9B
7 changed files with 143 additions and 72 deletions

View File

@ -40,8 +40,6 @@ service:
enabletaskcomments: true
# Whether totp is enabled. In most cases you want to leave that enabled.
enabletotp: true
# If not empty, enables logging of crashes and unhandled errors in sentry.
sentrydsn: ''
# If not empty, this will enable `/test/{table}` endpoints which allow to put any content in the database.
# Used to reset the db before frontend tests. Because this is quite a dangerous feature allowing for lots of harm,
# each request made to this endpoint needs to provide an `Authorization: <token>` header with the token from below. <br/>
@ -61,6 +59,18 @@ service:
# You probably don't need to set this value, it was created specifically for usage on [try](https://try.vikunja.io).
demomode: false
sentry:
# If set to true, enables anonymous error tracking of api errors via Sentry. This allows us to gather more
# information about errors in order to debug and fix it.
enabled: false
# Configure the Sentry dsn used for api error tracking. Only used when Sentry is enabled for the api.
dsn: "https://440eedc957d545a795c17bbaf477497c@o1047380.ingest.sentry.io/4504254983634944"
# If set to true, enables anonymous error tracking of frontend errors via Sentry. This allows us to gather more
# information about errors in order to debug and fix it.
frontendenabled: false
# Configure the Sentry dsn used for frontend error tracking. Only used when Sentry is enabled for the frontend.
frontenddsn: "https://85694a2d757547cbbc90cd4b55c5a18d@o1047380.ingest.sentry.io/6024480"
database:
# Database type to use. Supported types are mysql, postgres and sqlite.
type: "sqlite"

View File

@ -23,10 +23,6 @@
// It has to be the full url, including the last /api/v1 part and port.
// You can change this if your api is not reachable on the same port as the frontend.
window.API_URL = 'http://localhost:3456/api/v1'
// Enable error tracking with sentry. If this is set to true, will send anonymized data to
// our sentry instance to notify us of potential problems.
window.SENTRY_ENABLED = false
window.SENTRY_DSN = 'https://85694a2d757547cbbc90cd4b55c5a18d@o1047380.ingest.sentry.io/6024480'
// If enabled, allows the user to nest projects infinitely, instead of the default 2 levels.
// This setting might change in the future or be removed completely.
window.PROJECT_INFINITE_NESTING_ENABLED = false

View File

@ -18,8 +18,8 @@ import {getBrowserLanguage, i18n, setLanguage} from './i18n'
declare global {
interface Window {
API_URL: string;
SENTRY_ENABLED: boolean;
SENTRY_DSN: string;
SENTRY_ENABLED?: boolean;
SENTRY_DSN?: string;
PROJECT_INFINITE_NESTING_ENABLED: boolean;
ALLOW_ICON_CHANGES: boolean;
CUSTOM_LOGO_URL?: string;

View File

@ -8,7 +8,7 @@ export default async function setupSentry(app: App, router: Router) {
Sentry.init({
app,
dsn: window.SENTRY_DSN,
dsn: window.SENTRY_DSN ?? '',
release: import.meta.env.VITE_PLUGIN_SENTRY_CONFIG.release,
dist: import.meta.env.VITE_PLUGIN_SENTRY_CONFIG.dist,
integrations: [

View File

@ -58,12 +58,16 @@ const (
ServiceTimeZone Key = `service.timezone`
ServiceEnableTaskComments Key = `service.enabletaskcomments`
ServiceEnableTotp Key = `service.enabletotp`
ServiceSentryDsn Key = `service.sentrydsn`
ServiceTestingtoken Key = `service.testingtoken`
ServiceEnableEmailReminders Key = `service.enableemailreminders`
ServiceEnableUserDeletion Key = `service.enableuserdeletion`
ServiceMaxAvatarSize Key = `service.maxavatarsize`
SentryEnabled Key = `sentry.enabled`
SentryDsn Key = `sentry.dsn`
SentryFrontendEnabled Key = `sentry.frontendenabled`
SentryFrontendDsn Key = `sentry.frontenddsn`
AuthLocalEnabled Key = `auth.local.enabled`
AuthOpenIDEnabled Key = `auth.openid.enabled`
AuthOpenIDProviders Key = `auth.openid.providers`
@ -306,6 +310,10 @@ func InitDefaultConfig() {
ServiceMaxAvatarSize.setDefault(1024)
ServiceDemoMode.setDefault(false)
// Sentry
SentryDsn.setDefault("https://440eedc957d545a795c17bbaf477497c@o1047380.ingest.sentry.io/4504254983634944")
SentryFrontendDsn.setDefault("https://85694a2d757547cbbc90cd4b55c5a18d@o1047380.ingest.sentry.io/6024480")
// Auth
AuthLocalEnabled.setDefault(true)
AuthOpenIDEnabled.setDefault(false)

View File

@ -114,39 +114,7 @@ func NewEcho() *echo.Echo {
// panic recover
e.Use(middleware.Recover())
if config.ServiceSentryDsn.GetString() != "" {
if err := sentry.Init(sentry.ClientOptions{
Dsn: config.ServiceSentryDsn.GetString(),
AttachStacktrace: true,
Release: version.Version,
}); err != nil {
log.Criticalf("Sentry init failed: %s", err)
}
defer sentry.Flush(5 * time.Second)
e.Use(sentryecho.New(sentryecho.Options{
Repanic: true,
}))
e.HTTPErrorHandler = func(err error, c echo.Context) {
// Only capture errors not already handled by echo
var herr *echo.HTTPError
if errors.As(err, &herr) && herr.Code > 403 {
hub := sentryecho.GetHubFromContext(c)
if hub != nil {
hub.WithScope(func(scope *sentry.Scope) {
scope.SetExtra("url", c.Request().URL)
hub.CaptureException(err)
})
} else {
sentry.CaptureException(err)
log.Debugf("Could not add context for sending error '%s' to sentry", err.Error())
}
log.Debugf("Error '%s' sent to sentry", err.Error())
}
e.DefaultHTTPErrorHandler(err, c)
}
}
setupSentry(e)
// Validation
e.Validator = &CustomValidator{}
@ -162,6 +130,44 @@ func NewEcho() *echo.Echo {
return e
}
func setupSentry(e *echo.Echo) {
if !config.SentryEnabled.GetBool() {
return
}
if err := sentry.Init(sentry.ClientOptions{
Dsn: config.SentryDsn.GetString(),
AttachStacktrace: true,
Release: version.Version,
}); err != nil {
log.Criticalf("Sentry init failed: %s", err)
}
defer sentry.Flush(5 * time.Second)
e.Use(sentryecho.New(sentryecho.Options{
Repanic: true,
}))
e.HTTPErrorHandler = func(err error, c echo.Context) {
// Only capture errors not already handled by echo
var herr *echo.HTTPError
if errors.As(err, &herr) && herr.Code > 403 {
hub := sentryecho.GetHubFromContext(c)
if hub != nil {
hub.WithScope(func(scope *sentry.Scope) {
scope.SetExtra("url", c.Request().URL)
hub.CaptureException(err)
})
} else {
sentry.CaptureException(err)
log.Debugf("Could not add context for sending error '%s' to sentry", err.Error())
}
log.Debugf("Error '%s' sent to sentry", err.Error())
}
e.DefaultHTTPErrorHandler(err, c)
}
}
// RegisterRoutes registers all routes for the application
func RegisterRoutes(e *echo.Echo) {

View File

@ -2,6 +2,7 @@ package routes
import (
"bytes"
"code.vikunja.io/api/pkg/config"
"errors"
"fmt"
"io"
@ -13,6 +14,7 @@ import (
"path/filepath"
"strings"
"sync"
"text/template"
"code.vikunja.io/api/frontend"
@ -22,20 +24,88 @@ import (
)
const (
indexFile = `index.html`
rootPath = `dist/`
cacheControlMax = `max-age=315360000, public, max-age=31536000, s-maxage=31536000, immutable`
cacheControlNone = `public, max-age=0, s-maxage=0, must-revalidate`
indexFile = `index.html`
rootPath = `dist/`
cacheControlMax = `max-age=315360000, public, max-age=31536000, s-maxage=31536000, immutable`
cacheControlNone = `public, max-age=0, s-maxage=0, must-revalidate`
configScriptTagTemplate = `
<script>
window.SENTRY_ENABLED = {{ .SENTRY_ENABLED }}
window.SENTRY_DSN = '{{ .SENTRY_DSN }}'
window.ALLOW_ICON_CHANGES = {{ .ALLOW_ICON_CHANGES }}
window.CUSTOM_LOGO_URL = '{{ .CUSTOM_LOGO_URL }}'
</script>`
)
// Because the files are embedded into the final binary, we can be absolutely sure the etag will never change
// and we can cache its generation pretty heavily.
var etagCache map[string]string
var etagLock sync.Mutex
var scriptConfigString string
var scriptConfigStringLock sync.Mutex
func init() {
etagCache = make(map[string]string)
etagLock = sync.Mutex{}
scriptConfigStringLock = sync.Mutex{}
}
func serveIndexFile(c echo.Context, assetFs http.FileSystem) (err error) {
index, err := assetFs.Open(path.Join(rootPath, indexFile))
if err != nil {
return err
}
defer index.Close()
if scriptConfigString == "" {
scriptConfigStringLock.Lock()
defer scriptConfigStringLock.Unlock()
// replace config variables
tmpl, err := template.New("config").Parse(configScriptTagTemplate)
if err != nil {
return err
}
var tplOutput bytes.Buffer
data := make(map[string]string)
data["SENTRY_ENABLED"] = "false"
if config.SentryFrontendEnabled.GetBool() {
data["SENTRY_ENABLED"] = "true"
}
data["SENTRY_DSN"] = config.SentryFrontendDsn.GetString()
data["ALLOW_ICON_CHANGES"] = "true" // TODO
data["CUSTOM_LOGO_URL"] = "" // TODO
err = tmpl.Execute(&tplOutput, data)
if err != nil {
return err
}
scriptConfig := tplOutput.String()
buf := bytes.Buffer{}
_, err = buf.ReadFrom(index)
if err != nil {
return err
}
scriptConfigString = strings.ReplaceAll(buf.String(), `<div id="app"></div>`, `<div id="app"></div>`+scriptConfig)
}
reader := strings.NewReader(scriptConfigString)
info, err := index.Stat()
if err != nil {
return err
}
etag, err := generateEtag(index, info.Name())
if err != nil {
return err
}
return serveFile(c, reader, info, etag)
}
// Copied from echo's middleware.StaticWithConfig simplified and adjusted for caching
@ -71,10 +141,8 @@ func static() echo.MiddlewareFunc {
return err
}
file, err = assetFs.Open(path.Join(rootPath, indexFile))
if err != nil {
return err
}
// Handle all other requests with the index file
return serveIndexFile(c, assetFs)
}
defer file.Close()
@ -85,24 +153,7 @@ func static() echo.MiddlewareFunc {
}
if info.IsDir() {
index, err := assetFs.Open(path.Join(name, indexFile))
if err != nil {
return next(c)
}
defer index.Close()
info, err = index.Stat()
if err != nil {
return err
}
etag, err := generateEtag(index, name)
if err != nil {
return err
}
return serveFile(c, index, info, etag)
return serveIndexFile(c, assetFs)
}
etag, err := generateEtag(file, name)
@ -133,7 +184,7 @@ func generateEtag(file http.File, name string) (etag string, err error) {
}
// copied from http.serveContent
func getMimeType(name string, file http.File) (mineType string, err error) {
func getMimeType(name string, file io.ReadSeeker) (mineType string, err error) {
mineType = mime.TypeByExtension(filepath.Ext(name))
if mineType == "" {
// read a chunk to decide between utf-8 text and binary
@ -149,7 +200,7 @@ func getMimeType(name string, file http.File) (mineType string, err error) {
return mineType, nil
}
func getCacheControlHeader(info os.FileInfo, file http.File) (header string, err error) {
func getCacheControlHeader(info os.FileInfo, file io.ReadSeeker) (header string, err error) {
// Don't cache service worker and related files
if info.Name() == "robots.txt" ||
info.Name() == "sw.js" ||
@ -187,7 +238,7 @@ func getCacheControlHeader(info os.FileInfo, file http.File) (header string, err
return cacheControlNone, nil
}
func serveFile(c echo.Context, file http.File, info os.FileInfo, etag string) error {
func serveFile(c echo.Context, file io.ReadSeeker, info os.FileInfo, etag string) error {
c.Response().Header().Set("Server", "Vikunja")
c.Response().Header().Set("Vary", "Accept-Encoding")