Skip to content

feat: support multiple concurrent Sentry instances#1469

Draft
ClemDNL wants to merge 1 commit into
kdlbs:mainfrom
ClemDNL:feature/multi-sentry-instanc-b7u
Draft

feat: support multiple concurrent Sentry instances#1469
ClemDNL wants to merge 1 commit into
kdlbs:mainfrom
ClemDNL:feature/multi-sentry-instanc-b7u

Conversation

@ClemDNL

@ClemDNL ClemDNL commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Summary

Converts the Sentry integration from a single install-wide config to multiple named Sentry instances that run concurrently (e.g. sentry.io SaaS + a self-hosted host + extra org tokens). Each issue-watch and issue-browse operation binds to a specific instance. Existing single-instance installs migrate transparently on boot.

Implements the saved task plan (model: multiple named install-wide instances with optional per-workspace scoping).

Backend

  • Store (sentry_configs): dropped the CHECK(id='singleton') singleton; rows are now id (UUID) + name + url + health. New id-keyed CRUD (ListConfigs/Get/Create/Update/Delete/HasAnyConfig/CountWatchesForInstance/UpdateAuthHealth(id,…)). On-boot migration rebuilds the legacy table, promotes the 'singleton' row to a generated id named after its URL host, exposes MigratedSingletonID(), and backfills sentry_issue_watches.sentry_instance_id.
  • Service: per-instance client cache (map[string]Client + per-id generation counter, preserving the TOCTOU race fix); browse methods take an instanceId; CreateInstance/UpdateInstance/DeleteInstance (409 ErrInstanceInUse when watches reference it); RecordAuthHealth probes every instance.
  • Secrets: per-instance key sentry:<id>:token (secretKeyFor). Provider migrates the legacy sentry:singleton:token crash-safely (in-memory id, with a sole-config recovery fallback).
  • Issue watches: bound to an instance (sentry_instance_id, validated on create/update); CheckIssueWatch routes to the watch's instance client; NewSentryIssueEvent + task metadata carry sentry_instance_id. Per-watch throttle gate unchanged.
  • HTTP: /api/v1/sentry/instances CRUD + /instances/:id/test + /test-connection; browse endpoints require ?instanceId=. New codes: SENTRY_INSTANCE_NOT_FOUND (404), SENTRY_INSTANCE_IN_USE (409), SENTRY_INSTANCE_REQUIRED (400).

Frontend

  • Settings page is now a list of instances (per-instance auth-status banner, add/edit/delete, 409 in-use delete surfaced).
  • Issue-watch dialog gains a required instance selector gating the org/project pickers; the issue browser threads an instanceId (auto when one instance, selector when many).
  • Types/API client updated; useSentryAvailable = "≥1 healthy instance".

Testing

  • Backend unit/handler/poller/orchestrator tests rewritten + extended: singleton→instance migration, multi-instance coexistence, per-instance client cache + invalidation, delete-in-use 409, instance-bound watch validation/routing, browse instanceId requirement, event metadata. All green; golangci-lint clean on changed packages.
  • Frontend typecheck + lint clean.
  • Browser automation (real backend with mock Sentry + next dev, driven via Chromium): created two instances (Production SaaS @ sentry.io + Self-hosted Staging @ a self-hosted host), both showing independent "✓ Authenticated" health; confirmed the watcher dialog's instance selector lists both instances and gates org/project. Playwright spec (e2e/tests/integrations/sentry-settings.spec.ts) updated for the two-instance + in-use-delete flows.

Migration / compat

Clean cutover: /config/instances (FE updated in the same change, no shim). DB + secret migration is idempotent and crash-safe.

Review in cubic

@coderabbitai

coderabbitai Bot commented Jun 22, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a1d00956-2655-4ef2-b0f9-ba699679a253

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The Sentry integration is migrated from a singleton configuration to a named multi-instance model. The backend gains instance CRUD APIs, per-instance secret keying, generation-safe client caching, schema migration from the legacy singleton row, and instance-scoped browse endpoints. The frontend adds instance selectors across the settings page, issue dialogs, and link button, with all API calls now requiring instanceId.

Changes

Sentry Multi-Instance Support

Layer / File(s) Summary
Data models and TypeScript contracts
apps/backend/internal/sentry/models.go, apps/web/lib/types/sentry.ts
Go SentryConfig gains ID/Name and legacySecretKeysecretKeyFor(instanceID); IssueWatch, NewSentryIssueEvent, and watch request types gain InstanceID; TypeScript SentryConfig, SetSentryConfigRequest, SentryIssueWatch, and watch request interfaces gain matching id/name/instanceId fields.
Store: schema migration and multi-instance CRUD
apps/backend/internal/sentry/store.go, apps/backend/internal/sentry/store_issue_watch.go, apps/backend/internal/sentry/store_test.go
initSchema detects legacy schema and calls migrateConfigsToInstances to rebuild the table under a UUID derived from the URL host, recording migratedSingletonID; addWatchInstanceColumn backfills watches; replaces singleton ops with ListConfigs, GetConfig(id), CreateConfig, UpdateConfig, DeleteConfig(id), HasAnyConfig, CountWatchesForInstance, and per-instance UpdateAuthHealth; issue-watch store adds sentry_instance_id to insert/select/update.
Service: per-instance client caching and instance CRUD
apps/backend/internal/sentry/service.go, apps/backend/internal/sentry/service_test.go, apps/backend/internal/sentry/poller_test.go
Service replaces singleton client/clientGen with clients/gens maps; adds ErrInstanceNotFound/ErrInstanceInUse; implements ListInstances, GetInstance, CreateInstance, UpdateInstance, DeleteInstance with secret persistence, deletion blocking, and async probe hooks; clientForInstance uses generation-safe TOCTOU protection; RecordAuthHealth iterates all instances.
Service: issue watch instance binding
apps/backend/internal/sentry/service_issue_watch.go, apps/backend/internal/sentry/service_issue_watch_test.go, apps/backend/internal/sentry/poller_issue_watch_test.go
CreateIssueWatch/UpdateIssueWatch call requireInstance to validate InstanceID; CheckIssueWatch resolves the Sentry client via clientForInstance(w.InstanceID); publishNewSentryIssueEvent includes InstanceID; applyIssueWatchPatch supports repointing InstanceID.
Boot migration, poller, and orchestrator wiring
apps/backend/internal/sentry/provider.go, apps/backend/internal/sentry/poller.go, apps/backend/internal/orchestrator/source_sentry.go, apps/backend/internal/integrations/AGENTS.md
provider.go calls migrateLegacySecret at boot to move the legacy token to the per-instance key; poller switches HasConfigHasAnyConfig; orchestrator BuildTaskRequest emits sentry_instance_id in task metadata; AGENTS.md documents the divergence from Jira/Linear.
HTTP handlers: instance CRUD and instance-scoped browse
apps/backend/internal/sentry/handlers.go, apps/backend/internal/sentry/handlers_test.go, apps/backend/internal/sentry/mock_controller.go
RegisterHTTPRoutes replaces /config with /instances* CRUD routes; new handlers map domain errors to 400/404/409/503; browse endpoints require instanceId query param returning 400 SENTRY_INSTANCE_REQUIRED when absent; respondError/respondErrorCode centralize error responses; mock_controller.go forwards instanceId to UpdateAuthHealth.
Frontend API client and availability hook
apps/web/lib/api/domains/sentry-api.ts, apps/web/hooks/domains/sentry/use-sentry-availability.ts
sentry-api.ts replaces config functions with instance CRUD exports and adds instanceId to all browse/search call signatures; use-sentry-availability.ts lists instances and checks hasSecret && lastOk to determine integration health.
Frontend UI: settings, dialogs, link button
apps/web/components/sentry/sentry-settings.tsx, apps/web/components/sentry/sentry-issue-watch-dialog.tsx, apps/web/components/sentry/sentry-issue-dialog.tsx, apps/web/components/sentry/sentry-link-button.tsx, apps/web/components/sentry/sentry-issue-watch-form.ts, apps/web/components/integrations/validated-popover.tsx
Settings becomes a multi-instance list with InstanceCard/InstanceFormCard and describeDeleteError for 409 responses; issue-watch dialog adds InstancePicker and useWatchInstances; issue search dialog adds useBrowseInstances and instance-scoped project loading; link button adds useSentryInstanceChoice; validated-popover.tsx gains aboveInput slot.
E2E infrastructure and multi-instance specs
apps/web/e2e/helpers/api-client.ts, apps/web/e2e/pages/sentry-settings-page.ts, apps/web/e2e/tests/integrations/sentry-settings.spec.ts
api-client.ts adds SentryInstanceRow type and helpers for listing/finding/polling instances and creating issue watches; SentrySettingsPage gains card-scoped locators and addInstance workflow; specs validate dual-instance independent health status and deletion blocking when a watch references an instance.

Sequence Diagram(s)

sequenceDiagram
  participant UI as Frontend UI
  participant API as sentry-api.ts
  participant Handlers as HTTP Handlers
  participant Service as Service
  participant Store as Store
  participant Secrets as SecretStore

  rect rgba(70, 130, 180, 0.5)
    note over UI,Secrets: Instance Create + Probe Flow
    UI->>API: createSentryInstance({ name, url, secret })
    API->>Handlers: POST /api/v1/sentry/instances
    Handlers->>Service: CreateInstance(ctx, req)
    Service->>Store: CreateConfig(cfg)
    Service->>Secrets: Set(secretKeyFor(id), token)
    Service-->>Service: go probeAuthFor(id) async
    Service-->>Handlers: *SentryConfig
    Handlers-->>API: 201 Created
    API-->>UI: SentryConfig { id, name }
  end

  rect rgba(100, 160, 100, 0.5)
    note over UI,Secrets: Browse Issues (instance-scoped)
    UI->>API: searchSentryIssues(instanceId, filter)
    API->>Handlers: GET /issues?instanceId=...
    Handlers->>Service: SearchIssues(ctx, instanceId, filter)
    Service->>Service: clientForInstance(instanceId)
    Service->>Secrets: Exists + Reveal(secretKeyFor(instanceId))
    Service-->>UI: issue list
  end

  rect rgba(180, 80, 80, 0.5)
    note over UI,Store: Delete Blocked by Watch
    UI->>API: deleteSentryInstance(id)
    API->>Handlers: DELETE /instances/:id
    Handlers->>Service: DeleteInstance(ctx, id)
    Service->>Store: CountWatchesForInstance(id)
    Store-->>Service: count > 0
    Service-->>Handlers: ErrInstanceInUse{ WatchCount }
    Handlers-->>UI: 409 SENTRY_INSTANCE_IN_USE + watchCount
  end
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • kdlbs/kandev#771: Both PRs modify ValidatedPopoverProps in apps/web/components/integrations/validated-popover.tsx; this PR adds the aboveInput slot that builds on the same component structure changed there.
  • kdlbs/kandev#1133: This PR directly extends the Sentry issue-watcher orchestrator source (source_sentry.go) that #1133 originally implemented, adding sentry_instance_id to the task metadata map.
  • kdlbs/kandev#1320: Both PRs modify apps/backend/internal/sentry/models.go to extend SentryConfig with per-instance fields; this PR carries that work forward to a full multi-instance CRUD and routing model.

Poem

🐰 Hopping through configs, no more just one,
Each Sentry instance gets its own key and fun!
A UUID for names, a secret stored right,
Migration at boot makes the old singleton light.
No watch left behind, each instance aligned —
The rabbit refactored, and left no row unsigned! 🎉

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 29.25% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title directly and clearly describes the main change: adding support for multiple concurrent Sentry instances instead of a single install-wide configuration.
Description check ✅ Passed The PR description comprehensively covers summary, backend/frontend changes, testing approach, and migration strategy, aligning well with the template structure despite not following exact section headings.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Jun 22, 2026

Copy link
Copy Markdown

Greptile Summary

This PR converts the Sentry integration from a single install-wide singleton to N named instances stored in sentry_configs, each with its own UUID, secret key (sentry:<id>:token), and per-instance client cache. A boot-time migration promotes the legacy singleton row, re-keys the stored token, and backfills sentry_instance_id onto existing issue watches.

  • Backend: sentry_configs is rebuilt (DROP + recreate in a transaction) to remove the CHECK(id='singleton') constraint; new ListConfigs/Create/Update/Delete/CountWatchesForInstance store methods replace the upsert; service holds map[string]Client with per-id generation counters (preserving the existing TOCTOU-safe invalidation pattern); DeleteInstance blocks with HTTP 409 when watches reference the instance.
  • Frontend: settings page becomes a multi-instance list; the issue-watch dialog gains a required instance selector; browse endpoints now require ?instanceId=; useSentryAvailable reports true when ≥1 instance has hasSecret && lastOk.

Confidence Score: 3/5

Safe to merge on fresh installs; existing installs that experience a crash mid-upgrade risk losing all watch-to-instance bindings, causing watches to silently stop firing.

The multi-instance refactor is thorough and well-tested. The concern is in the two-step boot migration: migrateConfigsToInstances commits first, then addWatchInstanceColumn runs separately. migrateLegacySecret already handles this crash window with a sole-config fallback, but addWatchInstanceColumn does not — if the process dies between those two calls, the backfill is permanently skipped on subsequent boots and pre-existing watches are left with sentry_instance_id = empty string, silently failing on every poll. This is recoverable manually but not automatic, and there is no logged warning that watches are in a broken state.

apps/backend/internal/sentry/store.go — specifically addWatchInstanceColumn, which needs the same sole-config crash-recovery path that migrateLegacySecret in provider.go already implements.

Important Files Changed

Filename Overview
apps/backend/internal/sentry/store.go Core schema + migration rewrite: removes CHECK singleton constraint via DROP+CREATE tx, adds ListConfigs/CreateConfig/UpdateConfig/DeleteConfig/CountWatchesForInstance; watch backfill in addWatchInstanceColumn lacks the crash-recovery fallback that migrateLegacySecret has, creating a window where pre-existing watches lose their instance binding.
apps/backend/internal/sentry/service.go Service converted from singleton to per-instance map[string]Client with per-id generation counters; CreateInstance/UpdateInstance/DeleteInstance added; DeleteInstance uses check-then-delete across two transactions (TOCTOU window); otherwise clean refactor preserving the existing invalidation safety pattern.
apps/backend/internal/sentry/provider.go Adds migrateLegacySecret with crash-safe recovery (sole-config fallback when migratedSingletonID is empty); best-effort with warn-only on failure; logic is sound.
apps/backend/internal/sentry/service_issue_watch.go CheckIssueWatch now routes through clientForInstance(w.InstanceID); requireInstance validates instance existence on create and update; event carries instanceId; all looks correct.
apps/backend/internal/sentry/handlers.go CRUD routes for /instances, shared httpTestInstance across two routes (correct empty-id behavior), writeMissingInstance for browse endpoints without instanceId; clean refactor.
apps/backend/internal/sentry/store_issue_watch.go Adds sentry_instance_id to insert/select/update column lists and issueWatchRow; straightforward column addition.
apps/web/components/sentry/sentry-settings.tsx Settings page refactored from singleton form to multi-instance list; per-instance auth banner, add/edit/delete, 409 in-use error surfaced with watch count; looks complete.
apps/web/lib/api/domains/sentry-api.ts API client updated to /instances CRUD; testSentryConnection routes to /instances/:id/test or /test-connection correctly; instanceId threaded through browse endpoints.
apps/web/hooks/domains/sentry/use-sentry-availability.ts useSentryAvailable now reports true when any instance has hasSecret && lastOk; correct multi-instance semantics.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Boot as Server Boot
    participant Store as Store.initSchema
    participant Provider as provider.migrateLegacySecret
    participant SecretStore as SecretStore

    Boot->>Store: initSchema()
    Store->>Store: addConfigURLColumn() [noop if done]
    Store->>Store: migrateConfigsToInstances()
    Note over Store: DROP+CREATE in tx, assigns new UUID, sets migratedSingletonID
    Store->>Store: addWatchInstanceColumn()
    Note over Store: ALTER + backfill using migratedSingletonID
    Note over Store: crash here leaves migratedSingletonID=empty on next boot
    Boot->>Provider: migrateLegacySecret(store, secrets)
    Provider->>Store: MigratedSingletonID()
    alt migratedSingletonID is empty
        Provider->>Store: ListConfigs()
        Store-->>Provider: [cfg] exactly 1 → use cfg.ID
    end
    Provider->>SecretStore: Exists(sentry:id:token)
    alt not exists
        Provider->>SecretStore: Reveal(sentry:singleton:token)
        Provider->>SecretStore: Set(sentry:id:token, value)
        Provider->>SecretStore: Delete(sentry:singleton:token)
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Boot as Server Boot
    participant Store as Store.initSchema
    participant Provider as provider.migrateLegacySecret
    participant SecretStore as SecretStore

    Boot->>Store: initSchema()
    Store->>Store: addConfigURLColumn() [noop if done]
    Store->>Store: migrateConfigsToInstances()
    Note over Store: DROP+CREATE in tx, assigns new UUID, sets migratedSingletonID
    Store->>Store: addWatchInstanceColumn()
    Note over Store: ALTER + backfill using migratedSingletonID
    Note over Store: crash here leaves migratedSingletonID=empty on next boot
    Boot->>Provider: migrateLegacySecret(store, secrets)
    Provider->>Store: MigratedSingletonID()
    alt migratedSingletonID is empty
        Provider->>Store: ListConfigs()
        Store-->>Provider: [cfg] exactly 1 → use cfg.ID
    end
    Provider->>SecretStore: Exists(sentry:id:token)
    alt not exists
        Provider->>SecretStore: Reveal(sentry:singleton:token)
        Provider->>SecretStore: Set(sentry:id:token, value)
        Provider->>SecretStore: Delete(sentry:singleton:token)
    end
Loading

Comments Outside Diff (1)

  1. apps/backend/internal/sentry/store.go, line 852-876 (link)

    P1 Watch backfill has no crash-recovery path

    addWatchInstanceColumn only backfills sentry_instance_id when s.migratedSingletonID != "", which is only set by migrateConfigsToInstances in the same boot. If the process crashes after migrateConfigsToInstances commits (new table in place) but before addWatchInstanceColumn runs — for example, the OOM killer fires between those two calls in initSchema — then on the next boot migrateConfigsToInstances sees the name column and returns without setting migratedSingletonID. addWatchInstanceColumn then adds the column (since it's missing) but skips the backfill, leaving all pre-existing watches with sentry_instance_id = ''. clientForInstance returns ErrNotConfigured for an empty ID, so every pre-existing watch silently stops firing.

    migrateLegacySecret already handles this crash window by falling back to "exactly-one-config" recovery when MigratedSingletonID() is empty. The same pattern is needed here: when migratedSingletonID == "", look up the sole config (if any) and use its ID for the UPDATE ... WHERE sentry_instance_id = '' backfill.

Reviews (1): Last reviewed commit: "feat: support multiple concurrent Sentry..." | Re-trigger Greptile

Comment on lines 330 to 350
return nil, err
}
return client.ListOrganizations(ctx)
}

// ListProjects returns the projects the stored token can access.
func (s *Service) ListProjects(ctx context.Context) ([]SentryProject, error) {
client, err := s.clientFor(ctx)
// ListProjects returns the projects the instance's token can access.
func (s *Service) ListProjects(ctx context.Context, instanceID string) ([]SentryProject, error) {
client, err := s.clientForInstance(ctx, instanceID)
if err != nil {
return nil, err
}
return client.ListProjects(ctx)
}

// SearchIssues runs a filtered search. The caller supplies the org/project to
// search — there is no install-wide default to fall back on.
func (s *Service) SearchIssues(ctx context.Context, filter SearchFilter, cursor string) (*SearchResult, error) {
client, err := s.clientFor(ctx)
// SearchIssues runs a filtered search against one instance. The caller supplies
// the org/project to search — there is no install-wide default to fall back on.
func (s *Service) SearchIssues(ctx context.Context, instanceID string, filter SearchFilter, cursor string) (*SearchResult, error) {
client, err := s.clientForInstance(ctx, instanceID)
if err != nil {
return nil, err
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 TOCTOU race in DeleteInstance

CountWatchesForInstance and DeleteConfig run in separate transactions. If a watch is created for this instance in the gap between the two calls — by a concurrent CreateIssueWatch request — the delete proceeds and the newly created watch now references a non-existent config. Subsequent polling for that watch will call clientForInstancestore.GetConfignilErrNotConfigured, meaning the watch silently never fires and there is no UI indication that the instance it points to is gone.

Wrapping both operations in a single SQLite transaction (read the count and delete the row in one BEGIN...COMMIT) eliminates the window entirely.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

8 issues found across 30 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/backend/internal/sentry/handlers_test.go">

<violation number="1" location="apps/backend/internal/sentry/handlers_test.go:78">
P2: Missing HTTP test coverage for the UpdateInstance (PUT /api/v1/sentry/instances/:id) endpoint</violation>
</file>

<file name="apps/web/components/sentry/sentry-issue-dialog.tsx">

<violation number="1" location="apps/web/components/sentry/sentry-issue-dialog.tsx:179">
P2: Switching Sentry instances does not reset org/project filters or search results, leaving stale cross-instance state in the UI. Add a reset of `filter`, `issues`, `nextCursor`, and `isLast` when `instanceId` changes so the old instance's org slug, project slug, and issue list do not persist after the switch.</violation>
</file>

<file name="apps/backend/internal/sentry/store_issue_watch.go">

<violation number="1" location="apps/backend/internal/sentry/store_issue_watch.go:208">
P1: UpdateIssueWatch allows changing sentry_instance_id in-place but does not reset the watch's dedup rows or last_polled_at. Previously-seen issue_short_id values from the old instance will continue to suppress matching issues on the new instance (since dedup is keyed by watch_id + short_id, not instance), and retained last_polled_at can skip historical issues on the new instance. Consider clearing watch state (dedup rows and last_polled_at) when the instance ID changes, or reject instance changes on the update path.</violation>
</file>

<file name="apps/web/components/sentry/sentry-link-button.tsx">

<violation number="1" location="apps/web/components/sentry/sentry-link-button.tsx:39">
P2: Instance-list fetch failures are silently swallowed, creating a non-recoverable UI dead-end where the user can see the Link button and submit an issue key but receives a 'Select a Sentry instance' error with no picker visible.</violation>
</file>

<file name="apps/backend/internal/sentry/store.go">

<violation number="1" location="apps/backend/internal/sentry/store.go:308">
P2: Migration/backfill is not restart-safe: legacy watches may remain with empty sentry_instance_id after an interrupted upgrade</violation>
</file>

<file name="apps/web/components/sentry/sentry-settings.tsx">

<violation number="1" location="apps/web/components/sentry/sentry-settings.tsx:423">
P2: Concurrent fetch responses can cause stale state overwrites in `useSentryInstances`; add a request-version guard before applying fetched instance lists.</violation>

<violation number="2" location="apps/web/components/sentry/sentry-settings.tsx:534">
P1: Add and edit forms can be shown concurrently, producing duplicate input IDs and test IDs. The `adding` and `editingId` states are independent with no mutual exclusion, so a user can open the "Add instance" form while an existing instance is in edit mode. Both `InstanceFormCard` components use hardcoded IDs (`sentry-name`, `sentry-url`, `sentry-secret`) and identical `data-testid` values, creating invalid duplicate DOM IDs and ambiguous test selectors.</violation>
</file>

<file name="apps/backend/internal/sentry/service.go">

<violation number="1" location="apps/backend/internal/sentry/service.go:208">
P1: `DeleteInstance` has a race window between the watch-count check and the delete. A concurrent watch create can attach to this instance after the count check but before deletion, leaving an orphaned watch that can no longer resolve its config. Make the in-use check and delete atomic in a single DB transaction.</violation>
</file>

Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.

Re-trigger cubic

}
_, err = s.db.ExecContext(ctx, `
UPDATE sentry_issue_watches SET workflow_id = ?, workflow_step_id = ?, filter_json = ?,
UPDATE sentry_issue_watches SET sentry_instance_id = ?, workflow_id = ?, workflow_step_id = ?, filter_json = ?,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: UpdateIssueWatch allows changing sentry_instance_id in-place but does not reset the watch's dedup rows or last_polled_at. Previously-seen issue_short_id values from the old instance will continue to suppress matching issues on the new instance (since dedup is keyed by watch_id + short_id, not instance), and retained last_polled_at can skip historical issues on the new instance. Consider clearing watch state (dedup rows and last_polled_at) when the instance ID changes, or reject instance changes on the update path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/backend/internal/sentry/store_issue_watch.go, line 208:

<comment>UpdateIssueWatch allows changing sentry_instance_id in-place but does not reset the watch's dedup rows or last_polled_at. Previously-seen issue_short_id values from the old instance will continue to suppress matching issues on the new instance (since dedup is keyed by watch_id + short_id, not instance), and retained last_polled_at can skip historical issues on the new instance. Consider clearing watch state (dedup rows and last_polled_at) when the instance ID changes, or reject instance changes on the update path.</comment>

<file context>
@@ -203,11 +205,11 @@ func (s *Store) UpdateIssueWatch(ctx context.Context, w *IssueWatch) error {
 	}
 	_, err = s.db.ExecContext(ctx, `
-		UPDATE sentry_issue_watches SET workflow_id = ?, workflow_step_id = ?, filter_json = ?,
+		UPDATE sentry_issue_watches SET sentry_instance_id = ?, workflow_id = ?, workflow_step_id = ?, filter_json = ?,
 			agent_profile_id = ?, executor_profile_id = ?, prompt = ?,
 			enabled = ?, poll_interval_seconds = ?, max_inflight_tasks = ?, updated_at = ?
</file context>

const disableTest = missingSecret;
const { toast } = useToast();
const { instances, loading, reload } = useSentryInstances();
const [adding, setAdding] = useState(false);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Add and edit forms can be shown concurrently, producing duplicate input IDs and test IDs. The adding and editingId states are independent with no mutual exclusion, so a user can open the "Add instance" form while an existing instance is in edit mode. Both InstanceFormCard components use hardcoded IDs (sentry-name, sentry-url, sentry-secret) and identical data-testid values, creating invalid duplicate DOM IDs and ambiguous test selectors.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/components/sentry/sentry-settings.tsx, line 534:

<comment>Add and edit forms can be shown concurrently, producing duplicate input IDs and test IDs. The `adding` and `editingId` states are independent with no mutual exclusion, so a user can open the "Add instance" form while an existing instance is in edit mode. Both `InstanceFormCard` components use hardcoded IDs (`sentry-name`, `sentry-url`, `sentry-secret`) and identical `data-testid` values, creating invalid duplicate DOM IDs and ambiguous test selectors.</comment>

<file context>
@@ -374,42 +463,133 @@ function EnabledPill() {
-  const disableTest = missingSecret;
+  const { toast } = useToast();
+  const { instances, loading, reload } = useSentryInstances();
+  const [adding, setAdding] = useState(false);
+  const [editingId, setEditingId] = useState<string | null>(null);
+
</file context>

// with ErrInstanceInUse when issue watches still reference the instance, so a
// delete can never silently orphan watches.
func (s *Service) DeleteInstance(ctx context.Context, id string) error {
n, err := s.store.CountWatchesForInstance(ctx, id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: DeleteInstance has a race window between the watch-count check and the delete. A concurrent watch create can attach to this instance after the count check but before deletion, leaving an orphaned watch that can no longer resolve its config. Make the in-use check and delete atomic in a single DB transaction.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/backend/internal/sentry/service.go, line 208:

<comment>`DeleteInstance` has a race window between the watch-count check and the delete. A concurrent watch create can attach to this instance after the count check but before deletion, leaving an orphaned watch that can no longer resolve its config. Make the in-use check and delete atomic in a single DB transaction.</comment>

<file context>
@@ -93,76 +98,155 @@ func (s *Service) Store() *Store {
+// with ErrInstanceInUse when issue watches still reference the instance, so a
+// delete can never silently orphan watches.
+func (s *Service) DeleteInstance(ctx context.Context, id string) error {
+	n, err := s.store.CountWatchesForInstance(ctx, id)
+	if err != nil {
+		return err
</file context>

ctrl, router, _ := newTestController(t)
seedConfig(t, ctrl)
req := httptest.NewRequest(http.MethodGet, "/api/v1/sentry/config", nil)
func TestHTTPInstances_CreateListGetDelete(t *testing.T) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Missing HTTP test coverage for the UpdateInstance (PUT /api/v1/sentry/instances/:id) endpoint

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/backend/internal/sentry/handlers_test.go, line 78:

<comment>Missing HTTP test coverage for the UpdateInstance (PUT /api/v1/sentry/instances/:id) endpoint</comment>

<file context>
@@ -30,59 +30,160 @@ func newTestController(t *testing.T) (*Controller, *gin.Engine, *fakeClient) {
-	ctrl, router, _ := newTestController(t)
-	seedConfig(t, ctrl)
-	req := httptest.NewRequest(http.MethodGet, "/api/v1/sentry/config", nil)
+func TestHTTPInstances_CreateListGetDelete(t *testing.T) {
+	_, router, _ := newTestController(t)
+
</file context>

return;
}
if (loaded) return;
if (loadedFor.current === instanceId) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Switching Sentry instances does not reset org/project filters or search results, leaving stale cross-instance state in the UI. Add a reset of filter, issues, nextCursor, and isLast when instanceId changes so the old instance's org slug, project slug, and issue list do not persist after the switch.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/components/sentry/sentry-issue-dialog.tsx, line 179:

<comment>Switching Sentry instances does not reset org/project filters or search results, leaving stale cross-instance state in the UI. Add a reset of `filter`, `issues`, `nextCursor`, and `isLast` when `instanceId` changes so the old instance's org slug, project slug, and issue list do not persist after the switch.</comment>

<file context>
@@ -146,47 +160,94 @@ function toSearchFilter(filter: FilterState): SentrySearchFilter {
       return;
     }
-    if (loaded) return;
+    if (loadedFor.current === instanceId) return;
     let cancelled = false;
     (async () => {
</file context>

setInstances(list);
if (list.length === 1) setSelected(list[0].id);
})
.catch(() => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Instance-list fetch failures are silently swallowed, creating a non-recoverable UI dead-end where the user can see the Link button and submit an issue key but receives a 'Select a Sentry instance' error with no picker visible.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/components/sentry/sentry-link-button.tsx, line 39:

<comment>Instance-list fetch failures are silently swallowed, creating a non-recoverable UI dead-end where the user can see the Link button and submit an issue key but receives a 'Select a Sentry instance' error with no picker visible.</comment>

<file context>
@@ -13,12 +16,79 @@ type SentryLinkButtonProps = {
+        setInstances(list);
+        if (list.length === 1) setSelected(list[0].id);
+      })
+      .catch(() => {
+        if (!cancelled) setInstances([]);
+      });
</file context>

return fmt.Errorf("add sentry_instance_id column: %w", err)
}
}
if s.migratedSingletonID != "" {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Migration/backfill is not restart-safe: legacy watches may remain with empty sentry_instance_id after an interrupted upgrade

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/backend/internal/sentry/store.go, line 308:

<comment>Migration/backfill is not restart-safe: legacy watches may remain with empty sentry_instance_id after an interrupted upgrade</comment>

<file context>
@@ -158,6 +288,37 @@ func (s *Store) addIssueWatchLastErrorColumns() error {
+			return fmt.Errorf("add sentry_instance_id column: %w", err)
+		}
+	}
+	if s.migratedSingletonID != "" {
+		if _, err := s.db.Exec(
+			`UPDATE sentry_issue_watches SET sentry_instance_id = ? WHERE sentry_instance_id = ''`,
</file context>

const cfg = (await fetchSentryConfig()) ?? null;
setConfig(cfg);
setForm(configToForm(cfg));
setInstances(await listSentryInstances());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Concurrent fetch responses can cause stale state overwrites in useSentryInstances; add a request-version guard before applying fetched instance lists.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/components/sentry/sentry-settings.tsx, line 423:

<comment>Concurrent fetch responses can cause stale state overwrites in `useSentryInstances`; add a request-version guard before applying fetched instance lists.</comment>

<file context>
@@ -173,142 +215,214 @@ function TestResultAlert({ result }: { result: TestSentryConnectionResult | null
-      const cfg = (await fetchSentryConfig()) ?? null;
-      setConfig(cfg);
-      setForm(configToForm(cfg));
+      setInstances(await listSentryInstances());
     } catch (err) {
-      toast({ description: `Failed to load Sentry config: ${String(err)}`, variant: "error" });
</file context>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
apps/backend/internal/sentry/handlers_test.go (1)

105-105: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Check the unmarshal error for consistency and clearer test failures.

The discarded error on line 105 is inconsistent with the pattern used elsewhere in this file (lines 70-72, 91-93, 275-277). If JSON unmarshaling fails, the test will fail with a misleading message ("expected 1 instance, got 0") instead of a clear decode error, making debugging harder.

🔧 Suggested fix
 _ = json.Unmarshal(w.Body.Bytes(), &listResp)
+if err := json.Unmarshal(w.Body.Bytes(), &listResp); err != nil {
+	t.Fatalf("decode list: %v", err)
+}
-if len(listResp.Instances) != 1 {
+if len(listResp.Instances) != 1 {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/backend/internal/sentry/handlers_test.go` at line 105, The
json.Unmarshal error on line 105 is being discarded with the blank identifier
(_), which is inconsistent with how errors are handled in other parts of this
file (lines 70-72, 91-93, 275-277). Instead of ignoring the error, capture it
and add a check similar to the pattern used elsewhere in the file to ensure that
if unmarshaling fails, the test will fail with the actual decode error message
rather than a misleading message about unexpected data. Apply the same
error-checking pattern used in the other test cases to the json.Unmarshal call
for the listResp variable.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/backend/internal/sentry/store.go`:
- Line 65: The sentry_instance_id column lacks database-level referential
integrity constraints, allowing orphaned watches when an instance is deleted
concurrently with watch creation. Add a foreign key constraint on the
sentry_instance_id column that references the instances table with DELETE
RESTRICT to prevent deletion of instances with existing watches, or refactor the
watch creation logic (particularly in the operations around line ranges 405-408
and 421-427) and the DeleteConfig function to use atomic transactions that make
the instance existence check and watch insert indivisible, eliminating the race
condition between CountWatchesForInstance and DeleteConfig.
- Around line 224-228: The migration in the Commit block saves the randomly
generated replacement ID only to the in-memory variable s.migratedSingletonID,
but if the process exits after tx.Commit() succeeds but before
addWatchInstanceColumn runs, the next boot will see the migration already
applied while s.migratedSingletonID remains empty, causing legacy watches to
stay bound incorrectly. Move the persistence of the legacy-to-new ID mapping
into the same database transaction before the commit, include the watch backfill
logic (addWatchInstanceColumn) within that same transaction, or implement
recovery logic in addWatchInstanceColumn to query the database for the migrated
config ID when s.migratedSingletonID is empty to ensure the migration remains
recoverable at every step.

---

Nitpick comments:
In `@apps/backend/internal/sentry/handlers_test.go`:
- Line 105: The json.Unmarshal error on line 105 is being discarded with the
blank identifier (_), which is inconsistent with how errors are handled in other
parts of this file (lines 70-72, 91-93, 275-277). Instead of ignoring the error,
capture it and add a check similar to the pattern used elsewhere in the file to
ensure that if unmarshaling fails, the test will fail with the actual decode
error message rather than a misleading message about unexpected data. Apply the
same error-checking pattern used in the other test cases to the json.Unmarshal
call for the listResp variable.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1e25f478-5fbc-49df-a385-785e6e14c97a

📥 Commits

Reviewing files that changed from the base of the PR and between c7f2366 and 2e11a6b.

📒 Files selected for processing (30)
  • apps/backend/internal/integrations/AGENTS.md
  • apps/backend/internal/orchestrator/source_sentry.go
  • apps/backend/internal/orchestrator/source_sentry_test.go
  • apps/backend/internal/sentry/handlers.go
  • apps/backend/internal/sentry/handlers_test.go
  • apps/backend/internal/sentry/mock_controller.go
  • apps/backend/internal/sentry/models.go
  • apps/backend/internal/sentry/poller.go
  • apps/backend/internal/sentry/poller_issue_watch_test.go
  • apps/backend/internal/sentry/poller_test.go
  • apps/backend/internal/sentry/provider.go
  • apps/backend/internal/sentry/service.go
  • apps/backend/internal/sentry/service_issue_watch.go
  • apps/backend/internal/sentry/service_issue_watch_test.go
  • apps/backend/internal/sentry/service_test.go
  • apps/backend/internal/sentry/store.go
  • apps/backend/internal/sentry/store_issue_watch.go
  • apps/backend/internal/sentry/store_test.go
  • apps/web/components/integrations/validated-popover.tsx
  • apps/web/components/sentry/sentry-issue-dialog.tsx
  • apps/web/components/sentry/sentry-issue-watch-dialog.tsx
  • apps/web/components/sentry/sentry-issue-watch-form.ts
  • apps/web/components/sentry/sentry-link-button.tsx
  • apps/web/components/sentry/sentry-settings.tsx
  • apps/web/e2e/helpers/api-client.ts
  • apps/web/e2e/pages/sentry-settings-page.ts
  • apps/web/e2e/tests/integrations/sentry-settings.spec.ts
  • apps/web/hooks/domains/sentry/use-sentry-availability.ts
  • apps/web/lib/api/domains/sentry-api.ts
  • apps/web/lib/types/sentry.ts

CREATE TABLE IF NOT EXISTS sentry_issue_watches (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL,
sentry_instance_id TEXT NOT NULL DEFAULT '',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Enforce instance/watch referential integrity atomically.

CountWatchesForInstance and DeleteConfig are separate operations, while sentry_instance_id has no DB-level restriction. A concurrent watch create can validate an instance before another request deletes it, leaving an orphaned watch that later cannot resolve a client. Add a foreign key with delete restriction, or use atomic conditional insert/delete paths that make the existence check and write indivisible.

Also applies to: 405-408, 421-427

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/backend/internal/sentry/store.go` at line 65, The sentry_instance_id
column lacks database-level referential integrity constraints, allowing orphaned
watches when an instance is deleted concurrently with watch creation. Add a
foreign key constraint on the sentry_instance_id column that references the
instances table with DELETE RESTRICT to prevent deletion of instances with
existing watches, or refactor the watch creation logic (particularly in the
operations around line ranges 405-408 and 421-427) and the DeleteConfig function
to use atomic transactions that make the instance existence check and watch
insert indivisible, eliminating the race condition between
CountWatchesForInstance and DeleteConfig.

Comment on lines +224 to +228
if err := tx.Commit(); err != nil {
return err
}
s.migratedSingletonID = newID
return nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Make the singleton migration recoverable before committing the generated ID.

Line 224 commits a random replacement ID, but Line 227 only keeps that ID in memory and Line 308 relies on it later to backfill watches. If the process exits after the config rebuild commits but before addWatchInstanceColumn runs, the next boot sees the config schema already migrated, migratedSingletonID stays empty, and legacy watches remain bound to sentry_instance_id = ''. Move the watch backfill into the same recoverable migration transaction, persist the legacy→new ID mapping, or recover the sole migrated config ID when empty legacy watches are present.

Also applies to: 308-313

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/backend/internal/sentry/store.go` around lines 224 - 228, The migration
in the Commit block saves the randomly generated replacement ID only to the
in-memory variable s.migratedSingletonID, but if the process exits after
tx.Commit() succeeds but before addWatchInstanceColumn runs, the next boot will
see the migration already applied while s.migratedSingletonID remains empty,
causing legacy watches to stay bound incorrectly. Move the persistence of the
legacy-to-new ID mapping into the same database transaction before the commit,
include the watch backfill logic (addWatchInstanceColumn) within that same
transaction, or implement recovery logic in addWatchInstanceColumn to query the
database for the migrated config ID when s.migratedSingletonID is empty to
ensure the migration remains recoverable at every step.

@ClemDNL ClemDNL marked this pull request as draft June 23, 2026 07:09
@carlosflorencio

carlosflorencio commented Jul 2, 2026

Copy link
Copy Markdown
Member

Just to check in, are you going to proceed with this one? you have another PR in draft 💪

Also, i am changing a bit the integrations settings in another PR

Each integration settings will have a top right workspace selector
image

So you can configure different sentry settings per workspace, is it what you're trying to accomplish here?

@ClemDNL

ClemDNL commented Jul 3, 2026

Copy link
Copy Markdown
Contributor Author

Yes, I still need to properly review and test this. I've been quite busy with work, but will find time for this ASAP. Thanks for the push, and notification about the setting changes.
As a result I will wait for you to merge it, before resuming this.
I need to asses if that could address my use case, as I might still need multiple sentry instance within one workspace.

@ClemDNL ClemDNL force-pushed the feature/multi-sentry-instanc-b7u branch from 2e11a6b to e3d2abb Compare July 3, 2026 08:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants