feat(sentry): support self-hosted instances via configurable URL (#1320)

This commit is contained in:
Clément Fiere
2026-06-10 23:06:42 +02:00
committed by GitHub
parent dd182acfd2
commit a03d65fdf8
12 changed files with 529 additions and 31 deletions
+9 -2
View File
@@ -20,7 +20,11 @@ const SecretKey = "sentry:singleton:token"
// The token is stored separately in the encrypted secret store under SecretKey.
type SentryConfig struct {
AuthMethod string `json:"authMethod" db:"auth_method"`
HasSecret bool `json:"hasSecret" db:"-"`
// URL is the base URL of the Sentry instance (e.g. https://sentry.io for
// SaaS, or a self-hosted host). The REST client appends /api/0. Defaults
// to the sentry.io SaaS endpoint when left blank.
URL string `json:"url" db:"url"`
HasSecret bool `json:"hasSecret" db:"-"`
// LastCheckedAt / LastOk / LastError are written by the background auth
// poller so the UI can render a "connected/disconnected + checked Xs ago"
// indicator without doing its own probing.
@@ -36,7 +40,10 @@ type SentryConfig struct {
// is retained; when non-empty it replaces the stored value.
type SetConfigRequest struct {
AuthMethod string `json:"authMethod"`
Secret string `json:"secret"`
// URL is the Sentry instance base URL. Optional: blank defaults to the
// sentry.io SaaS endpoint, preserving the prior single-tenant behavior.
URL string `json:"url"`
Secret string `json:"secret"`
}
// TestConnectionResult reports what the backend learned when pinging Sentry
+53 -13
View File
@@ -13,31 +13,56 @@ import (
"time"
)
// SentryAPIEndpoint is the SaaS API base. Phase 1 targets sentry.io only;
// self-hosted instances will need a per-config base-URL field later.
const SentryAPIEndpoint = "https://sentry.io/api/0"
// DefaultSentryURL is the SaaS base URL used when no custom instance URL is
// configured. Self-hosted installs override it with their own host; the REST
// client appends apiPathPrefix to whichever base it is given.
const DefaultSentryURL = "https://sentry.io"
// apiPathPrefix is appended to the instance base URL to reach the REST API.
const apiPathPrefix = "/api/0"
const userAgent = "kandev/1.0 (+https://github.com/kdlbs/kandev)"
// RESTClient talks to Sentry's REST API using a Bearer auth token.
type RESTClient struct {
http *http.Client
endpoint string
endpoint string // REST API base, including the apiPathPrefix suffix
baseURL string // instance base URL shown to users (no apiPathPrefix)
token string
maxBodySize int64
}
// NewRESTClient builds a client from a SentryConfig + secret. The config is
// accepted for symmetry with other integrations; only the token is read today.
func NewRESTClient(_ *SentryConfig, secret string) *RESTClient {
// NewRESTClient builds a client from a SentryConfig + secret. The config's URL
// selects the instance (SaaS or self-hosted); a blank URL falls back to
// DefaultSentryURL so existing single-tenant installs keep working.
func NewRESTClient(cfg *SentryConfig, secret string) *RESTClient {
rawURL := ""
if cfg != nil {
rawURL = cfg.URL
}
base := resolveBaseURL(rawURL)
return &RESTClient{
http: &http.Client{Timeout: 30 * time.Second},
endpoint: SentryAPIEndpoint,
endpoint: base + apiPathPrefix,
baseURL: base,
token: secret,
maxBodySize: 8 << 20, // 8 MB — issue payloads can include event samples.
}
}
// resolveBaseURL normalizes an instance base URL (scheme defaulted, trailing
// slash trimmed), falling back to the SaaS default when empty. The result is
// the user-facing host root; apiPathPrefix is appended separately to form the
// REST endpoint, so it is kept out of error messages to avoid users pasting a
// /api/0-suffixed value back into the Instance URL field.
func resolveBaseURL(rawURL string) string {
base := normalizeSentryURL(rawURL)
if base == "" {
base = DefaultSentryURL
}
return base
}
// do executes a GET against path (relative to endpoint), parses query params,
// and decodes the JSON body into out. Returns the response so callers can read
// headers (e.g. Link for pagination).
@@ -104,11 +129,7 @@ type whoamiResponse struct {
func (c *RESTClient) TestAuth(ctx context.Context) (*TestConnectionResult, error) {
var data whoamiResponse
if _, err := c.do(ctx, "/", nil, &data); err != nil {
var apiErr *APIError
if errors.As(err, &apiErr) {
return &TestConnectionResult{OK: false, Error: apiErr.Error()}, nil
}
return &TestConnectionResult{OK: false, Error: err.Error()}, nil
return &TestConnectionResult{OK: false, Error: c.classifyProbeError(err)}, nil
}
name := data.User.Name
if name == "" {
@@ -122,6 +143,25 @@ func (c *RESTClient) TestAuth(ctx context.Context) (*TestConnectionResult, error
}, nil
}
// classifyProbeError turns a /api/0/ probe failure into a user-facing message
// that separates a credential problem from an instance-URL problem. With
// self-hosted support the URL is a common misconfiguration, so a transport
// failure (host unreachable / wrong scheme) or a non-Sentry HTTP response
// (any status other than 401/403, including a 200 whose body isn't the
// expected JSON) blames the configured URL, while 401/403 blames the token.
func (c *RESTClient) classifyProbeError(err error) string {
var apiErr *APIError
if errors.As(err, &apiErr) {
switch apiErr.StatusCode {
case http.StatusUnauthorized, http.StatusForbidden:
return fmt.Sprintf("authentication failed — check the auth token (HTTP %d)", apiErr.StatusCode)
default:
return fmt.Sprintf("%s did not respond like a Sentry API (HTTP %d) — check the instance URL", c.baseURL, apiErr.StatusCode)
}
}
return fmt.Sprintf("could not reach Sentry at %s — check the instance URL (%s)", c.baseURL, err.Error())
}
// --- organizations ---
// listOrganizationsMaxPages bounds the pagination loop so a misbehaving API or
@@ -19,6 +19,7 @@ func newMockServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
// httptest server without needing a mockable URL on the production constructor.
func pointTo(c *RESTClient, url string) *RESTClient {
c.endpoint = url
c.baseURL = url
return c
}
@@ -275,3 +276,96 @@ func TestBuildIssueQueryString(t *testing.T) {
}
}
}
// TestNewRESTClient_BuildsEndpointFromConfigURL locks in that a self-hosted
// instance URL becomes the API base with the /api/0 suffix appended, that a
// trailing slash and missing scheme are normalized, and that an empty URL
// falls back to the SaaS default.
func TestNewRESTClient_BuildsEndpointFromConfigURL(t *testing.T) {
cases := []struct {
name string
url string
want string
}{
{"self-hosted", "https://sentry.example.com", "https://sentry.example.com/api/0"},
{"trailing slash", "https://sentry.example.com/", "https://sentry.example.com/api/0"},
{"scheme defaulted", "sentry.example.com", "https://sentry.example.com/api/0"},
{"empty falls back to saas", "", DefaultSentryURL + "/api/0"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
c := NewRESTClient(&SentryConfig{URL: tc.url}, "tok")
if c.endpoint != tc.want {
t.Errorf("endpoint = %q, want %q", c.endpoint, tc.want)
}
})
}
}
// TestRESTClient_TestAuth_WrongURL covers the self-hosted failure mode: when
// the configured instance answers but isn't a Sentry API (non-JSON body, or a
// 5xx/4xx other than 401/403), the probe must blame the instance URL rather
// than the auth token.
func TestRESTClient_TestAuth_WrongURL(t *testing.T) {
t.Run("non-json body", func(t *testing.T) {
ts := newMockServer(t, func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte("<!doctype html><html><body>Not Sentry</body></html>"))
})
c := pointTo(NewRESTClient(&SentryConfig{}, "tok"), ts.URL)
res, err := c.TestAuth(context.Background())
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if res.OK {
t.Error("expected OK=false for non-Sentry response")
}
if !strings.Contains(res.Error, "instance URL") {
t.Errorf("expected instance-URL hint, got %q", res.Error)
}
})
t.Run("server error", func(t *testing.T) {
ts := newMockServer(t, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte("bad gateway"))
})
c := pointTo(NewRESTClient(&SentryConfig{}, "tok"), ts.URL)
res, _ := c.TestAuth(context.Background())
if res.OK || !strings.Contains(res.Error, "instance URL") {
t.Errorf("expected instance-URL hint for 502, got %+v", res)
}
})
t.Run("unreachable host", func(t *testing.T) {
s := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
dead := s.URL
s.Close() // close so the next request is refused immediately
c := pointTo(NewRESTClient(&SentryConfig{}, "tok"), dead)
res, err := c.TestAuth(context.Background())
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if res.OK || !strings.Contains(res.Error, "instance URL") {
t.Errorf("expected instance-URL hint for unreachable host, got %+v", res)
}
})
}
// TestRESTClient_TestAuth_Unauthorized_BlamesToken complements the wrong-URL
// cases: a 401/403 must read as an auth-token problem, not a URL problem.
func TestRESTClient_TestAuth_Unauthorized_BlamesToken(t *testing.T) {
ts := newMockServer(t, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"detail":"Invalid token"}`))
})
c := pointTo(NewRESTClient(&SentryConfig{}, "bad"), ts.URL)
res, _ := c.TestAuth(context.Background())
if res.OK {
t.Fatal("expected OK=false")
}
if strings.Contains(res.Error, "instance URL") {
t.Errorf("401 should blame the token, not the URL: %q", res.Error)
}
if !strings.Contains(res.Error, "token") {
t.Errorf("expected token hint, got %q", res.Error)
}
}
+65 -4
View File
@@ -4,6 +4,8 @@ import (
"context"
"errors"
"fmt"
"net/url"
"strings"
"sync"
"time"
@@ -102,7 +104,7 @@ func (s *Service) SetConfig(ctx context.Context, req *SetConfigRequest) (*Sentry
if err := validateConfigRequest(req); err != nil {
return nil, fmt.Errorf("%w: %s", ErrInvalidConfig, err.Error())
}
cfg := &SentryConfig{AuthMethod: req.AuthMethod}
cfg := &SentryConfig{AuthMethod: req.AuthMethod, URL: req.URL}
if err := s.store.UpsertConfig(ctx, cfg); err != nil {
return nil, fmt.Errorf("upsert sentry config: %w", err)
}
@@ -296,8 +298,11 @@ func (s *Service) invalidateClient() {
// resolveCredentials picks credentials for a test: inline if the request
// carries a secret, otherwise the stored secret.
func (s *Service) resolveCredentials(ctx context.Context, req *SetConfigRequest) (*SentryConfig, string, error) {
cfg := &SentryConfig{AuthMethod: req.AuthMethod}
cfg := &SentryConfig{AuthMethod: req.AuthMethod, URL: normalizeSentryURL(req.URL)}
if req.Secret != "" {
if cfg.URL == "" {
cfg.URL = DefaultSentryURL
}
return cfg, req.Secret, nil
}
if s.secrets == nil {
@@ -315,12 +320,23 @@ func (s *Service) resolveCredentials(ctx context.Context, req *SetConfigRequest)
if storeErr != nil {
s.log.Warn("sentry: load stored config for credential resolution failed", zap.Error(storeErr))
}
if stored != nil && cfg.AuthMethod == "" {
cfg.AuthMethod = stored.AuthMethod
if stored != nil {
if cfg.AuthMethod == "" {
cfg.AuthMethod = stored.AuthMethod
}
if cfg.URL == "" {
cfg.URL = stored.URL
}
}
if cfg.URL == "" {
cfg.URL = DefaultSentryURL
}
return cfg, secret, nil
}
// validateConfigRequest normalizes and validates a config request in place: it
// defaults a blank auth method and instance URL, rejects unknown auth methods,
// and rejects URLs the HTTP client could not use (see validateSentryURL).
func validateConfigRequest(req *SetConfigRequest) error {
if req.AuthMethod == "" {
req.AuthMethod = AuthMethodAuthToken
@@ -328,5 +344,50 @@ func validateConfigRequest(req *SetConfigRequest) error {
if req.AuthMethod != AuthMethodAuthToken {
return fmt.Errorf("unknown auth method: %q", req.AuthMethod)
}
req.URL = normalizeSentryURL(req.URL)
if req.URL == "" {
req.URL = DefaultSentryURL
}
return validateSentryURL(req.URL)
}
// normalizeSentryURL trims whitespace and trailing slashes and prepends
// https:// when the user typed only a host (e.g. "sentry.acme.com"). Without a
// scheme the Go HTTP client fails with "unsupported protocol scheme". An empty
// input is returned as-is so callers can apply the sentry.io default.
func normalizeSentryURL(raw string) string {
s := strings.TrimSpace(raw)
s = strings.TrimRight(s, "/")
if s == "" {
return s
}
if !strings.Contains(s, "://") {
s = "https://" + s
}
return s
}
// validateSentryURL rejects an instance URL the HTTP client could not use: it
// must parse, carry an http/https scheme, name a host, and be a bare host root.
// A path/query/fragment is rejected because apiPathPrefix ("/api/0") is later
// appended by string concatenation — e.g. "https://host?x=1" would otherwise
// become the malformed base "https://host?x=1/api/0".
func validateSentryURL(raw string) error {
u, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("invalid instance URL: %s", err.Error())
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("instance URL must use http or https: %q", raw)
}
if u.Host == "" {
return fmt.Errorf("instance URL must include a host: %q", raw)
}
if u.Path != "" && u.Path != "/" {
return fmt.Errorf("instance URL must be a host root without a path: %q", raw)
}
if u.RawQuery != "" || u.Fragment != "" {
return fmt.Errorf("instance URL must not include a query or fragment: %q", raw)
}
return nil
}
@@ -413,3 +413,130 @@ func TestService_ClientFor_InvalidateDuringBuild(t *testing.T) {
t.Errorf("expected factory called at least twice (once per clientFor after invalidation), got %d", got)
}
}
// TestService_SetConfig_DefaultsURLToSentryIO asserts a blank instance URL is
// stored as the sentry.io SaaS default.
func TestService_SetConfig_DefaultsURLToSentryIO(t *testing.T) {
f := newSvcFixture(t)
cfg, err := f.svc.SetConfig(context.Background(), &SetConfigRequest{
AuthMethod: AuthMethodAuthToken, Secret: "tok",
})
if err != nil {
t.Fatalf("set: %v", err)
}
if cfg.URL != DefaultSentryURL {
t.Errorf("expected URL defaulted to %q, got %q", DefaultSentryURL, cfg.URL)
}
}
// TestService_SetConfig_NormalizesAndPersistsURL asserts a host-only URL is
// normalized (scheme added, trailing slash trimmed) before being stored.
func TestService_SetConfig_NormalizesAndPersistsURL(t *testing.T) {
f := newSvcFixture(t)
cfg, err := f.svc.SetConfig(context.Background(), &SetConfigRequest{
AuthMethod: AuthMethodAuthToken, Secret: "tok", URL: "sentry.example.com/",
})
if err != nil {
t.Fatalf("set: %v", err)
}
if cfg.URL != "https://sentry.example.com" {
t.Errorf("expected normalized URL, got %q", cfg.URL)
}
}
// TestService_SetConfig_RejectsMalformedURL asserts a non-http(s) URL is
// rejected at save time.
func TestService_SetConfig_RejectsMalformedURL(t *testing.T) {
f := newSvcFixture(t)
_, err := f.svc.SetConfig(context.Background(), &SetConfigRequest{
AuthMethod: AuthMethodAuthToken, Secret: "tok", URL: "ftp://nope",
})
if err == nil {
t.Error("expected validation error for non-http(s) URL")
}
}
// TestService_TestConnection_PassesConfiguredURL ensures a pre-save test uses
// the URL the user typed in the form, so a self-hosted instance can be
// validated before the config is persisted.
func TestService_TestConnection_PassesConfiguredURL(t *testing.T) {
f := newSvcFixture(t)
var sawURL string
f.svc.clientFn = func(cfg *SentryConfig, _ string) Client {
sawURL = cfg.URL
return f.client
}
if _, err := f.svc.TestConnection(context.Background(), &SetConfigRequest{
AuthMethod: AuthMethodAuthToken, Secret: "inline", URL: "https://sentry.example.com",
}); err != nil {
t.Fatalf("test: %v", err)
}
if sawURL != "https://sentry.example.com" {
t.Errorf("expected configured URL passed to client, got %q", sawURL)
}
}
// TestService_TestConnection_FallsBackToStoredURL covers the post-save "Test
// connection" path (blank secret, blank URL in the request): the stored
// instance URL must be used rather than silently reverting to sentry.io.
func TestService_TestConnection_FallsBackToStoredURL(t *testing.T) {
f := newSvcFixture(t)
ctx := context.Background()
if _, err := f.svc.SetConfig(ctx, &SetConfigRequest{
AuthMethod: AuthMethodAuthToken, Secret: "stored", URL: "https://sentry.example.com",
}); err != nil {
t.Fatalf("set: %v", err)
}
waitForAuthProbe(t, f)
var sawURL string
f.svc.clientFn = func(cfg *SentryConfig, _ string) Client {
sawURL = cfg.URL
return f.client
}
if _, err := f.svc.TestConnection(ctx, &SetConfigRequest{AuthMethod: AuthMethodAuthToken}); err != nil {
t.Fatalf("test: %v", err)
}
if sawURL != "https://sentry.example.com" {
t.Errorf("expected stored URL used, got %q", sawURL)
}
}
// TestService_SetConfig_RejectsNonRootURL asserts URLs carrying a path, query,
// or fragment are rejected, so /api/0 cannot be appended to a malformed base.
func TestService_SetConfig_RejectsNonRootURL(t *testing.T) {
cases := []struct {
name string
url string
}{
{"path", "https://sentry.example.com/some/path"},
{"query", "https://sentry.example.com?x=1"},
{"fragment", "https://sentry.example.com#frag"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f := newSvcFixture(t)
_, err := f.svc.SetConfig(context.Background(), &SetConfigRequest{
AuthMethod: AuthMethodAuthToken, Secret: "tok", URL: tc.url,
})
if err == nil {
t.Errorf("expected rejection of non-root URL %q", tc.url)
}
})
}
}
// TestService_SetConfig_AllowsHostRootWithTrailingSlash guards the boundary:
// a bare host root (with or without a trailing slash) must still be accepted
// and normalized, so the non-root rejection above doesn't over-reach.
func TestService_SetConfig_AllowsHostRootWithTrailingSlash(t *testing.T) {
f := newSvcFixture(t)
cfg, err := f.svc.SetConfig(context.Background(), &SetConfigRequest{
AuthMethod: AuthMethodAuthToken, Secret: "tok", URL: "https://sentry.example.com/",
})
if err != nil {
t.Fatalf("host root with trailing slash should be accepted: %v", err)
}
if cfg.URL != "https://sentry.example.com" {
t.Errorf("expected trailing slash trimmed, got %q", cfg.URL)
}
}
+33 -4
View File
@@ -30,6 +30,7 @@ const createTablesSQL = `
CREATE TABLE IF NOT EXISTS sentry_configs (
id TEXT PRIMARY KEY CHECK(id = 'singleton'),
auth_method TEXT NOT NULL,
url TEXT NOT NULL DEFAULT 'https://sentry.io',
last_checked_at DATETIME,
last_ok INTEGER NOT NULL DEFAULT 0,
last_error TEXT NOT NULL DEFAULT '',
@@ -73,16 +74,43 @@ const createTablesSQL = `
// singletonID is the synthetic primary key of the (only) row in sentry_configs.
const singletonID = "singleton"
// initSchema creates the integration tables when absent and applies the
// additive column migrations that bring older databases to the current schema.
func (s *Store) initSchema() error {
if _, err := s.db.Exec(createTablesSQL); err != nil {
return err
}
if err := s.addConfigURLColumn(); err != nil {
return err
}
if err := s.addMaxInflightTasksColumn(); err != nil {
return err
}
return s.addIssueWatchLastErrorColumns()
}
// addConfigURLColumn brings older databases up to the current schema by adding
// the url column to sentry_configs when missing. Existing rows — all SaaS
// installs, since this predates self-hosted support — backfill to the
// sentry.io default (mirrors DefaultSentryURL). A fresh install hits the
// column-already-present branch since createTablesSQL declares the column.
func (s *Store) addConfigURLColumn() error {
cols, err := s.tableColumns("sentry_configs")
if err != nil {
return err
}
if len(cols) == 0 {
return nil
}
if _, ok := cols["url"]; ok {
return nil
}
if _, err := s.db.Exec(`ALTER TABLE sentry_configs ADD COLUMN url TEXT NOT NULL DEFAULT 'https://sentry.io'`); err != nil {
return fmt.Errorf("add url column: %w", err)
}
return nil
}
// addMaxInflightTasksColumn brings older databases up to the current schema by
// adding the max_inflight_tasks column to sentry_issue_watches when missing.
// Existing rows backfill to the default (5). A fresh install hits the
@@ -156,7 +184,7 @@ func (s *Store) tableColumns(table string) (map[string]struct{}, error) {
return cols, rows.Err()
}
const selectConfigColumns = `auth_method,
const selectConfigColumns = `auth_method, url,
last_checked_at, last_ok, last_error, created_at, updated_at`
// GetConfig returns the singleton Sentry config, or nil when no row exists.
@@ -182,12 +210,13 @@ func (s *Store) UpsertConfig(ctx context.Context, cfg *SentryConfig) error {
}
cfg.UpdatedAt = now
_, err := s.db.ExecContext(ctx, `
INSERT INTO sentry_configs (id, auth_method, created_at, updated_at)
VALUES (?, ?, ?, ?)
INSERT INTO sentry_configs (id, auth_method, url, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
auth_method = excluded.auth_method,
url = excluded.url,
updated_at = excluded.updated_at`,
singletonID, cfg.AuthMethod, cfg.CreatedAt, cfg.UpdatedAt)
singletonID, cfg.AuthMethod, cfg.URL, cfg.CreatedAt, cfg.UpdatedAt)
return err
}
@@ -130,3 +130,65 @@ func TestStore_UpdateAuthHealth(t *testing.T) {
t.Errorf("expected last_error preserved, got %q", cfg.LastError)
}
}
// TestStore_UpsertGetConfig_RoundTripsURL asserts the instance URL persists
// through an upsert/get cycle.
func TestStore_UpsertGetConfig_RoundTripsURL(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
cfg := &SentryConfig{AuthMethod: AuthMethodAuthToken, URL: "https://sentry.example.com"}
if err := store.UpsertConfig(ctx, cfg); err != nil {
t.Fatalf("upsert: %v", err)
}
got, err := store.GetConfig(ctx)
if err != nil || got == nil {
t.Fatalf("get: %v / %v", err, got)
}
if got.URL != "https://sentry.example.com" {
t.Errorf("url not persisted: got %q", got.URL)
}
}
// TestStore_MigratesURLColumn seeds a pre-self-hosted sentry_configs table (no
// url column) and verifies NewStore adds the column, backfilling the existing
// SaaS row to the sentry.io default rather than an empty string.
func TestStore_MigratesURLColumn(t *testing.T) {
raw, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
raw.SetMaxOpenConns(1)
raw.SetMaxIdleConns(1)
db := sqlx.NewDb(raw, "sqlite3")
t.Cleanup(func() { _ = db.Close() })
now := time.Now().UTC()
if _, err := db.Exec(`
CREATE TABLE sentry_configs (
id TEXT PRIMARY KEY CHECK(id = 'singleton'),
auth_method TEXT NOT NULL,
last_checked_at DATETIME,
last_ok INTEGER NOT NULL DEFAULT 0,
last_error TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
)`); err != nil {
t.Fatalf("create legacy schema: %v", err)
}
if _, err := db.Exec(`INSERT INTO sentry_configs (id, auth_method, created_at, updated_at)
VALUES ('singleton', ?, ?, ?)`, AuthMethodAuthToken, now, now); err != nil {
t.Fatalf("seed legacy row: %v", err)
}
store, err := NewStore(db, db)
if err != nil {
t.Fatalf("new store (migrate): %v", err)
}
cfg, err := store.GetConfig(context.Background())
if err != nil || cfg == nil {
t.Fatalf("get after migrate: %v / %v", err, cfg)
}
if cfg.URL != DefaultSentryURL {
t.Errorf("expected legacy row backfilled to %q, got %q", DefaultSentryURL, cfg.URL)
}
}
+37 -6
View File
@@ -26,21 +26,23 @@ import {
} from "@/lib/api/domains/sentry-api";
import {
SENTRY_AUTH_METHOD,
SENTRY_DEFAULT_URL,
type SentryConfig,
type TestSentryConnectionResult,
} from "@/lib/types/sentry";
import { SentryIssueWatchersSection } from "./sentry-issue-watchers-section";
type FormState = {
url: string;
secret: string;
};
const emptyForm: FormState = { secret: "" };
const emptyForm: FormState = { url: SENTRY_DEFAULT_URL, secret: "" };
function configToForm(_cfg: SentryConfig | null): FormState {
// Only the (write-only) secret is editable here; org/project are chosen
// per-watcher and per-browse, not stored install-wide.
return { secret: "" };
function configToForm(cfg: SentryConfig | null): FormState {
// Only the instance URL and the (write-only) secret are editable here;
// org/project are chosen per-watcher and per-browse, not stored install-wide.
return { url: cfg?.url || SENTRY_DEFAULT_URL, secret: "" };
}
function saveLabel(saving: boolean, hasConfig: boolean): string {
@@ -60,6 +62,33 @@ function configToHealth(config: SentryConfig | null): IntegrationAuthHealth | nu
type UpdateFn = <K extends keyof FormState>(key: K, value: FormState[K]) => void;
type UrlFieldProps = {
form: FormState;
loading: boolean;
update: UpdateFn;
};
function UrlField({ form, loading, update }: UrlFieldProps) {
return (
<div className="space-y-1.5">
<Label htmlFor="sentry-url">Instance URL</Label>
<Input
id="sentry-url"
data-testid="sentry-url-input"
type="url"
placeholder={SENTRY_DEFAULT_URL}
value={form.url}
onChange={(e) => update("url", e.target.value)}
disabled={loading}
/>
<p className="text-xs text-muted-foreground">
Base URL of your Sentry instance. Leave as {SENTRY_DEFAULT_URL} for Sentry SaaS, or point it
at a self-hosted install (e.g. https://sentry.your-company.com).
</p>
</div>
);
}
type SecretFieldProps = {
form: FormState;
loading: boolean;
@@ -220,7 +249,7 @@ function useSettingsActions({ form, setConfig, setForm, setTestResult }: Setting
setTesting(true);
setTestResult(null);
try {
const res = await testSentryConnection(form.secret || undefined);
const res = await testSentryConnection(form.secret || undefined, form.url || undefined);
setTestResult(res);
} catch (err) {
setTestResult({ ok: false, error: String(err) });
@@ -234,6 +263,7 @@ function useSettingsActions({ form, setConfig, setForm, setTestResult }: Setting
try {
const saved = await saveSentryConfig({
authMethod: SENTRY_AUTH_METHOD,
url: form.url,
secret: form.secret,
});
setConfig(saved);
@@ -360,6 +390,7 @@ export function SentryConnectionSection() {
<Card>
<CardContent className="space-y-4 pt-6">
<IntegrationAuthStatusBanner health={s.health} />
<UrlField form={s.form} loading={s.loading} update={s.update} />
<SecretField
form={s.form}
loading={s.loading}
@@ -1,6 +1,7 @@
import { type Locator, type Page } from "@playwright/test";
export class SentrySettingsPage {
readonly urlInput: Locator;
readonly secretInput: Locator;
readonly testButton: Locator;
readonly saveButton: Locator;
@@ -8,6 +9,7 @@ export class SentrySettingsPage {
readonly statusBanner: Locator;
constructor(private page: Page) {
this.urlInput = page.getByTestId("sentry-url-input");
this.secretInput = page.getByTestId("sentry-secret-input");
this.testButton = page.getByTestId("sentry-test-button");
this.saveButton = page.getByTestId("sentry-save-button");
@@ -36,4 +36,35 @@ test.describe("Sentry settings", () => {
await expect(settings.saveButton).toHaveText(/Update/i);
await expect(settings.deleteButton).toBeVisible();
});
// Self-hosted support: the instance URL defaults to sentry.io, accepts a
// custom host, and persists across reload via the saved config.
test("defaults the instance URL and persists a self-hosted URL", async ({
testPage,
apiClient,
}) => {
await apiClient.mockSentryReset();
await apiClient.mockSentrySetAuthResult({
ok: true,
userId: "u-2",
displayName: "Self Hosted",
});
const settings = new SentrySettingsPage(testPage);
await settings.goto();
// The URL field is pre-filled with the SaaS default.
await expect(settings.urlInput).toHaveValue("https://sentry.io");
// Point it at a self-hosted instance and save with a token.
await settings.urlInput.fill("https://sentry.acme.example.com");
await settings.secretInput.fill(TOKEN);
await settings.saveButton.click();
await expect(settings.saveButton).toHaveText(/Update/i);
await apiClient.waitForIntegrationAuthHealthy("sentry");
// Reload: the custom instance URL round-trips through the saved config.
await settings.goto();
await expect(settings.urlInput).toHaveValue("https://sentry.acme.example.com");
});
});
+9 -2
View File
@@ -37,13 +37,20 @@ export async function deleteSentryConfig(options?: ApiRequestOptions) {
});
}
export async function testSentryConnection(secret?: string, options?: ApiRequestOptions) {
export async function testSentryConnection(
secret?: string,
url?: string,
options?: ApiRequestOptions,
) {
const payload: { secret?: string; url?: string } = {};
if (secret) payload.secret = secret;
if (url) payload.url = url;
return fetchJson<TestSentryConnectionResult>(`/api/v1/sentry/config/test`, {
...options,
init: {
...(options?.init ?? {}),
method: "POST",
body: JSON.stringify(secret ? { secret } : {}),
body: JSON.stringify(payload),
},
});
}
+7
View File
@@ -2,8 +2,14 @@ export type SentryAuthMethod = "auth_token";
export const SENTRY_AUTH_METHOD: SentryAuthMethod = "auth_token";
// SENTRY_DEFAULT_URL is the SaaS base URL pre-filled in the settings form and
// used by the backend when no custom instance URL is configured. Self-hosted
// installs replace it with their own host.
export const SENTRY_DEFAULT_URL = "https://sentry.io";
export interface SentryConfig {
authMethod: SentryAuthMethod;
url: string;
hasSecret: boolean;
lastCheckedAt?: string | null;
lastOk: boolean;
@@ -14,6 +20,7 @@ export interface SentryConfig {
export interface SetSentryConfigRequest {
authMethod: SentryAuthMethod;
url: string;
secret: string;
}